refactor(handler,repo): replace strings.Contains error dispatch with typed sentinels (S-2)

Closes one 2026-04-24 audit finding (P2):

  - cat-s6-efc7f6f6bd50: 30 strings.Contains(err.Error(), ...) sites
    in internal/api/handler/ — brittle to repository-layer message
    changes, untyped against the actual failure mode.

Approach (Option B from prompt design notes):
  - New typed sentinels in internal/repository/errors.go:
      ErrNotFound, ErrForeignKeyConstraint
      IsForeignKeyError(err) helper (the only place substring
      matching at the lib/pq boundary is allowed; isolates the
      DB-driver string knowledge to one function).
  - New typed sentinel in internal/domain/errors.go:
      ErrValidation (reserved for future per-entity validation
      wrappers; not yet used by all handlers).
  - 49 sites in internal/repository/postgres/*.go updated to wrap
    sql.ErrNoRows-derived errors via fmt.Errorf("...: %w",
    repository.ErrNotFound).
  - 18 not-found handler sites + 2 FK-constraint handler sites
    refactored to errors.Is(err, repository.ErrNotFound) /
    repository.IsForeignKeyError(err).
  - 23 inline `fmt.Errorf("X not found")` test fixtures across
    handler tests rewrapped to wrap repository.ErrNotFound.
  - test_utils.go::ErrMockNotFound rewrapped to wrap
    repository.ErrNotFound; renewal_policy.go closure docblock
    updated to reflect the new convention.
  - integration test mockJobRepository.Get wraps repository.ErrNotFound.

CI regression guardrail:
- .github/workflows/ci.yml::"Forbidden strings.Contains(err.Error())
  regression guard (S-2)" greps for the three patterns ("not found",
  "violates foreign key", "RESTRICT") under internal/api/handler/
  and fails the build on regression.

Verification:
- go build ./... — clean
- go vet ./... — clean
- go test ./... -short -count=1 — all packages pass (handler +
  repository + service + integration)
- golangci-lint v2.11.4 run ./... — 0 issues
- S-2 guardrail dry-run on post-fix tree → empty (good)
- All sibling guardrails (S-1, G-3, D-1+D-2, B-1, L-1, H-1, C-1, F-1, P-1) pass

Audit findings closed:
- cat-s6-efc7f6f6bd50 (P2)

Deferred follow-ups:
- 6 domain-specific substring patterns still inline in handlers
  ("cannot approve", "cannot reject", "cannot be parsed",
  "no certificates found", "challenge password", "invalid"/
  "required" validation chains in profiles + agent_groups). Each
  needs its own typed sentinel, scoped per service. Documented
  by the S-2 CI guardrail's allowlist for closure-comments only.
- Per-entity not-found sentinels (Option A — ErrCertificateNotFound,
  ErrAgentNotFound, etc.) deferred. Generic ErrNotFound covers the
  current dispatch needs; per-entity precision would let handlers
  return entity-aware error bodies without a domain.Type field,
  but not blocking.
This commit is contained in:
shankar0123
2026-04-25 17:54:14 +00:00
parent 8a3086c4ae
commit 0e29c416b1
39 changed files with 265 additions and 107 deletions
+62
View File
@@ -0,0 +1,62 @@
// Package repository defines the repository-layer error sentinels that
// handlers map to HTTP status codes via errors.Is.
//
// S-2 closure (cat-s6-efc7f6f6bd50): pre-S-2 every handler-side
// not-found dispatch was a `strings.Contains(err.Error(), "not found")`
// site (30+ across internal/api/handler/*.go), brittle to any
// repository-layer message change and untyped against the actual
// failure mode. Post-S-2 the dispatch is type-checked: repositories
// wrap sql.ErrNoRows via fmt.Errorf("...: %w", repository.ErrNotFound)
// and FK constraint violations via repository.ErrForeignKeyConstraint;
// handlers consume via errors.Is. The substring matching is preserved
// at the lib/pq boundary inside `errors.go::isFKError` because the
// PostgreSQL driver returns un-typed *pq.Error values whose codes are
// the canonical signal — but it's confined to one helper rather than
// scattered across every handler file. See unified-audit.md
// cat-s6-efc7f6f6bd50 for the closure rationale.
package repository
import (
"errors"
"strings"
)
// ErrNotFound is the canonical sentinel for repository methods that
// return after sql.ErrNoRows (or its wrapped form). Handlers that
// surface a 404 should `errors.Is(err, repository.ErrNotFound)`
// rather than substring-match.
var ErrNotFound = errors.New("repository: row not found")
// ErrForeignKeyConstraint is the canonical sentinel for PostgreSQL
// FK / RESTRICT violations bubbling up from a DELETE or UPDATE.
// Handlers that surface a 409 Conflict should
// `errors.Is(err, repository.ErrForeignKeyConstraint)`.
//
// The B-1 closure introduced ErrRenewalPolicyInUse as the per-entity
// FK sentinel for renewal_policies; future per-entity FK sentinels
// (ErrIssuerInUse, ErrTeamInUse, ErrOwnerInUse) can wrap this generic
// one via fmt.Errorf("...: %w", ErrForeignKeyConstraint) so handlers
// can choose between generic-409 and entity-specific 409 dispatch.
var ErrForeignKeyConstraint = errors.New("repository: foreign key constraint violation")
// IsForeignKeyError detects PostgreSQL FK violation errors from the
// lib/pq driver via the canonical error-text patterns it emits. The
// substring matching is intentionally confined to this helper —
// callers should use this once at the repo layer to wrap into the
// typed ErrForeignKeyConstraint sentinel, then handlers consume via
// errors.Is.
//
// Patterns recognised:
// - "violates foreign key constraint" (the standard PG message)
// - "violates restrict" / "RESTRICT" (DELETE blocked by ON DELETE RESTRICT)
//
// Returns false for nil err so callers can defensively chain it.
func IsForeignKeyError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "violates foreign key") ||
strings.Contains(msg, "RESTRICT") ||
strings.Contains(msg, "violates restrict")
}
+6 -5
View File
@@ -1,6 +1,7 @@
package postgres
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"fmt"
@@ -73,7 +74,7 @@ func (r *AgentRepository) Get(ctx context.Context, id string) (*domain.Agent, er
agent, err := scanAgent(row)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("agent not found")
return nil, fmt.Errorf("agent not found: %w", repository.ErrNotFound)
}
return nil, fmt.Errorf("failed to query agent: %w", err)
}
@@ -170,7 +171,7 @@ func (r *AgentRepository) Update(ctx context.Context, agent *domain.Agent) error
}
if rows == 0 {
return fmt.Errorf("agent not found")
return fmt.Errorf("agent not found: %w", repository.ErrNotFound)
}
return nil
@@ -190,7 +191,7 @@ func (r *AgentRepository) Delete(ctx context.Context, id string) error {
}
if rows == 0 {
return fmt.Errorf("agent not found")
return fmt.Errorf("agent not found: %w", repository.ErrNotFound)
}
return nil
@@ -237,7 +238,7 @@ func (r *AgentRepository) UpdateHeartbeat(ctx context.Context, id string, metada
}
if rows == 0 {
return fmt.Errorf("agent not found")
return fmt.Errorf("agent not found: %w", repository.ErrNotFound)
}
return nil
@@ -259,7 +260,7 @@ func (r *AgentRepository) GetByAPIKey(ctx context.Context, keyHash string) (*dom
agent, err := scanAgent(row)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("agent not found")
return nil, fmt.Errorf("agent not found: %w", repository.ErrNotFound)
}
return nil, fmt.Errorf("failed to query agent: %w", err)
}
+4 -3
View File
@@ -1,6 +1,7 @@
package postgres
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"fmt"
@@ -50,7 +51,7 @@ func (r *AgentGroupRepository) Get(ctx context.Context, id string) (*domain.Agen
err := row.Scan(&g.ID, &g.Name, &g.Description, &g.MatchOS, &g.MatchArchitecture,
&g.MatchIPCIDR, &g.MatchVersion, &g.Enabled, &g.CreatedAt, &g.UpdatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("agent group not found: %s", id)
return nil, fmt.Errorf("agent group not found: %w", repository.ErrNotFound)
}
if err != nil {
return nil, fmt.Errorf("failed to get agent group: %w", err)
@@ -84,7 +85,7 @@ func (r *AgentGroupRepository) Update(ctx context.Context, group *domain.AgentGr
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("agent group not found: %s", group.ID)
return fmt.Errorf("agent group not found: %w", repository.ErrNotFound)
}
return nil
}
@@ -97,7 +98,7 @@ func (r *AgentGroupRepository) Delete(ctx context.Context, id string) error {
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("agent group not found: %s", id)
return fmt.Errorf("agent group not found: %w", repository.ErrNotFound)
}
return nil
}
+3 -3
View File
@@ -265,7 +265,7 @@ func (r *CertificateRepository) Get(ctx context.Context, id string) (*domain.Man
cert, err := r.scanCertificate(ctx, row)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("certificate not found")
return nil, fmt.Errorf("certificate not found: %w", repository.ErrNotFound)
}
return nil, fmt.Errorf("failed to query certificate: %w", err)
}
@@ -397,7 +397,7 @@ func (r *CertificateRepository) Update(ctx context.Context, cert *domain.Managed
}
if rows == 0 {
return fmt.Errorf("certificate not found")
return fmt.Errorf("certificate not found: %w", repository.ErrNotFound)
}
return nil
@@ -419,7 +419,7 @@ func (r *CertificateRepository) Archive(ctx context.Context, id string) error {
}
if rows == 0 {
return fmt.Errorf("certificate not found")
return fmt.Errorf("certificate not found: %w", repository.ErrNotFound)
}
return nil
+3 -3
View File
@@ -62,7 +62,7 @@ func (r *DiscoveryRepository) GetScan(ctx context.Context, id string) (*domain.D
&scan.ScanDurationMs, &scan.StartedAt, &scan.CompletedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("discovery scan not found: %s", id)
return nil, fmt.Errorf("discovery scan not found: %w", repository.ErrNotFound)
}
if err != nil {
return nil, fmt.Errorf("failed to get discovery scan: %w", err)
@@ -190,7 +190,7 @@ func (r *DiscoveryRepository) GetDiscovered(ctx context.Context, id string) (*do
&cert.CreatedAt, &cert.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("discovered certificate not found: %s", id)
return nil, fmt.Errorf("discovered certificate not found: %w", repository.ErrNotFound)
}
if err != nil {
return nil, fmt.Errorf("failed to get discovered certificate: %w", err)
@@ -317,7 +317,7 @@ func (r *DiscoveryRepository) UpdateDiscoveredStatus(ctx context.Context, id str
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return fmt.Errorf("discovered certificate not found: %s", id)
return fmt.Errorf("discovered certificate not found: %w", repository.ErrNotFound)
}
return nil
}
+2 -2
View File
@@ -113,7 +113,7 @@ func (r *HealthCheckRepository) Get(ctx context.Context, id string) (*domain.End
&check.CreatedAt, &check.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("health check not found: %s", id)
return nil, fmt.Errorf("health check not found: %w", repository.ErrNotFound)
}
if err != nil {
return nil, fmt.Errorf("get health check: %w", err)
@@ -299,7 +299,7 @@ func (r *HealthCheckRepository) GetByEndpoint(ctx context.Context, endpoint stri
&check.CreatedAt, &check.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("health check not found for endpoint: %s", endpoint)
return nil, fmt.Errorf("health check not found for endpoint: %w", repository.ErrNotFound)
}
if err != nil {
return nil, fmt.Errorf("get health check by endpoint: %w", err)
+4 -3
View File
@@ -1,6 +1,7 @@
package postgres
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"fmt"
@@ -69,7 +70,7 @@ func (r *IssuerRepository) Get(ctx context.Context, id string) (*domain.Issuer,
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("issuer not found")
return nil, fmt.Errorf("issuer not found: %w", repository.ErrNotFound)
}
return nil, fmt.Errorf("failed to query issuer: %w", err)
}
@@ -169,7 +170,7 @@ func (r *IssuerRepository) Update(ctx context.Context, issuer *domain.Issuer) er
}
if rows == 0 {
return fmt.Errorf("issuer not found")
return fmt.Errorf("issuer not found: %w", repository.ErrNotFound)
}
return nil
@@ -189,7 +190,7 @@ func (r *IssuerRepository) Delete(ctx context.Context, id string) error {
}
if rows == 0 {
return fmt.Errorf("issuer not found")
return fmt.Errorf("issuer not found: %w", repository.ErrNotFound)
}
return nil
+5 -4
View File
@@ -1,6 +1,7 @@
package postgres
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"fmt"
@@ -62,7 +63,7 @@ func (r *JobRepository) Get(ctx context.Context, id string) (*domain.Job, error)
job, err := scanJob(row)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("job not found")
return nil, fmt.Errorf("job not found: %w", repository.ErrNotFound)
}
return nil, fmt.Errorf("failed to query job: %w", err)
}
@@ -123,7 +124,7 @@ func (r *JobRepository) Update(ctx context.Context, job *domain.Job) error {
}
if rows == 0 {
return fmt.Errorf("job not found")
return fmt.Errorf("job not found: %w", repository.ErrNotFound)
}
return nil
@@ -143,7 +144,7 @@ func (r *JobRepository) Delete(ctx context.Context, id string) error {
}
if rows == 0 {
return fmt.Errorf("job not found")
return fmt.Errorf("job not found: %w", repository.ErrNotFound)
}
return nil
@@ -232,7 +233,7 @@ func (r *JobRepository) UpdateStatus(ctx context.Context, id string, status doma
}
if rows == 0 {
return fmt.Errorf("job not found")
return fmt.Errorf("job not found: %w", repository.ErrNotFound)
}
return nil
+4 -3
View File
@@ -1,6 +1,7 @@
package postgres
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"fmt"
@@ -68,7 +69,7 @@ func (r *NetworkScanRepository) Get(ctx context.Context, id string) (*domain.Net
&target.CreatedAt, &target.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("network scan target not found: %s", id)
return nil, fmt.Errorf("network scan target not found: %w", repository.ErrNotFound)
}
if err != nil {
return nil, fmt.Errorf("get network scan target: %w", err)
@@ -117,7 +118,7 @@ func (r *NetworkScanRepository) Update(ctx context.Context, target *domain.Netwo
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("network scan target not found: %s", target.ID)
return fmt.Errorf("network scan target not found: %w", repository.ErrNotFound)
}
return nil
}
@@ -130,7 +131,7 @@ func (r *NetworkScanRepository) Delete(ctx context.Context, id string) error {
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("network scan target not found: %s", id)
return fmt.Errorf("network scan target not found: %w", repository.ErrNotFound)
}
return nil
}
+4 -4
View File
@@ -174,7 +174,7 @@ func (r *NotificationRepository) UpdateStatus(ctx context.Context, id string, st
}
if rows == 0 {
return fmt.Errorf("notification not found")
return fmt.Errorf("notification not found: %w", repository.ErrNotFound)
}
return nil
@@ -336,7 +336,7 @@ func (r *NotificationRepository) RecordFailedAttempt(ctx context.Context, id str
// Same "not found" error shape as UpdateStatus above. The scheduler
// logs-and-continues on this so a concurrently-deleted row doesn't
// break the sweep.
return fmt.Errorf("notification not found")
return fmt.Errorf("notification not found: %w", repository.ErrNotFound)
}
return nil
}
@@ -368,7 +368,7 @@ func (r *NotificationRepository) MarkAsDead(ctx context.Context, id string, last
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rows == 0 {
return fmt.Errorf("notification not found")
return fmt.Errorf("notification not found: %w", repository.ErrNotFound)
}
return nil
}
@@ -405,7 +405,7 @@ func (r *NotificationRepository) Requeue(ctx context.Context, id string) error {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rows == 0 {
return fmt.Errorf("notification not found")
return fmt.Errorf("notification not found: %w", repository.ErrNotFound)
}
return nil
}
+4 -3
View File
@@ -1,6 +1,7 @@
package postgres
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"fmt"
@@ -61,7 +62,7 @@ func (r *OwnerRepository) Get(ctx context.Context, id string) (*domain.Owner, er
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("owner not found")
return nil, fmt.Errorf("owner not found: %w", repository.ErrNotFound)
}
return nil, fmt.Errorf("failed to query owner: %w", err)
}
@@ -110,7 +111,7 @@ func (r *OwnerRepository) Update(ctx context.Context, owner *domain.Owner) error
}
if rows == 0 {
return fmt.Errorf("owner not found")
return fmt.Errorf("owner not found: %w", repository.ErrNotFound)
}
return nil
@@ -130,7 +131,7 @@ func (r *OwnerRepository) Delete(ctx context.Context, id string) error {
}
if rows == 0 {
return fmt.Errorf("owner not found")
return fmt.Errorf("owner not found: %w", repository.ErrNotFound)
}
return nil
+3 -3
View File
@@ -63,7 +63,7 @@ func (r *PolicyRepository) GetRule(ctx context.Context, id string) (*domain.Poli
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("policy rule not found")
return nil, fmt.Errorf("policy rule not found: %w", repository.ErrNotFound)
}
return nil, fmt.Errorf("failed to query policy rule: %w", err)
}
@@ -114,7 +114,7 @@ func (r *PolicyRepository) UpdateRule(ctx context.Context, rule *domain.PolicyRu
}
if rows == 0 {
return fmt.Errorf("policy rule not found")
return fmt.Errorf("policy rule not found: %w", repository.ErrNotFound)
}
return nil
@@ -134,7 +134,7 @@ func (r *PolicyRepository) DeleteRule(ctx context.Context, id string) error {
}
if rows == 0 {
return fmt.Errorf("policy rule not found")
return fmt.Errorf("policy rule not found: %w", repository.ErrNotFound)
}
return nil
+4 -3
View File
@@ -1,6 +1,7 @@
package postgres
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"encoding/json"
@@ -64,7 +65,7 @@ func (r *ProfileRepository) Get(ctx context.Context, id string) (*domain.Certifi
p, err := scanProfile(row)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("profile not found")
return nil, fmt.Errorf("profile not found: %w", repository.ErrNotFound)
}
return nil, fmt.Errorf("failed to query profile: %w", err)
}
@@ -159,7 +160,7 @@ func (r *ProfileRepository) Update(ctx context.Context, profile *domain.Certific
}
if rows == 0 {
return fmt.Errorf("profile not found")
return fmt.Errorf("profile not found: %w", repository.ErrNotFound)
}
return nil
@@ -178,7 +179,7 @@ func (r *ProfileRepository) Delete(ctx context.Context, id string) error {
}
if rows == 0 {
return fmt.Errorf("profile not found")
return fmt.Errorf("profile not found: %w", repository.ErrNotFound)
}
return nil
@@ -72,7 +72,7 @@ func (r *RenewalPolicyRepository) Get(ctx context.Context, id string) (*domain.R
policy, err := scanRenewalPolicy(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("renewal policy not found: %s", id)
return nil, fmt.Errorf("renewal policy not found: %w", repository.ErrNotFound)
}
return nil, fmt.Errorf("failed to query renewal policy: %w", err)
}
@@ -252,7 +252,7 @@ func (r *RenewalPolicyRepository) Update(ctx context.Context, id string, policy
updated, err := scanRenewalPolicy(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("renewal policy not found: %s", id)
return fmt.Errorf("renewal policy not found: %w", repository.ErrNotFound)
}
if isUniqueViolation(err) {
return repository.ErrRenewalPolicyDuplicateName
@@ -283,7 +283,7 @@ func (r *RenewalPolicyRepository) Delete(ctx context.Context, id string) error {
return fmt.Errorf("failed to read RowsAffected for delete: %w", err)
}
if rows == 0 {
return fmt.Errorf("renewal policy not found: %s", id)
return fmt.Errorf("renewal policy not found: %w", repository.ErrNotFound)
}
return nil
}
+2 -1
View File
@@ -1,6 +1,7 @@
package postgres
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"fmt"
@@ -136,7 +137,7 @@ func (r *RevocationRepository) MarkIssuerNotified(ctx context.Context, id string
}
if rows == 0 {
return fmt.Errorf("revocation not found")
return fmt.Errorf("revocation not found: %w", repository.ErrNotFound)
}
return nil
+4 -3
View File
@@ -1,6 +1,7 @@
package postgres
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"fmt"
@@ -89,7 +90,7 @@ func (r *TargetRepository) Get(ctx context.Context, id string) (*domain.Deployme
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("target not found")
return nil, fmt.Errorf("target not found: %w", repository.ErrNotFound)
}
return nil, fmt.Errorf("failed to query target: %w", err)
}
@@ -174,7 +175,7 @@ func (r *TargetRepository) Update(ctx context.Context, target *domain.Deployment
}
if rows == 0 {
return fmt.Errorf("target not found")
return fmt.Errorf("target not found: %w", repository.ErrNotFound)
}
return nil
@@ -194,7 +195,7 @@ func (r *TargetRepository) Delete(ctx context.Context, id string) error {
}
if rows == 0 {
return fmt.Errorf("target not found")
return fmt.Errorf("target not found: %w", repository.ErrNotFound)
}
return nil
+4 -3
View File
@@ -1,6 +1,7 @@
package postgres
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"fmt"
@@ -61,7 +62,7 @@ func (r *TeamRepository) Get(ctx context.Context, id string) (*domain.Team, erro
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("team not found")
return nil, fmt.Errorf("team not found: %w", repository.ErrNotFound)
}
return nil, fmt.Errorf("failed to query team: %w", err)
}
@@ -108,7 +109,7 @@ func (r *TeamRepository) Update(ctx context.Context, team *domain.Team) error {
}
if rows == 0 {
return fmt.Errorf("team not found")
return fmt.Errorf("team not found: %w", repository.ErrNotFound)
}
return nil
@@ -128,7 +129,7 @@ func (r *TeamRepository) Delete(ctx context.Context, id string) error {
}
if rows == 0 {
return fmt.Errorf("team not found")
return fmt.Errorf("team not found: %w", repository.ErrNotFound)
}
return nil