Skip to content

jobs: give Unclassified DLQ rows a resolution path and retention exemptions (K-1) - #1345

Merged
radandevist merged 6 commits into
developfrom
lane/wt-863
Aug 25, 2026
Merged

jobs: give Unclassified DLQ rows a resolution path and retention exemptions (K-1)#1345
radandevist merged 6 commits into
developfrom
lane/wt-863

Conversation

@radandevist

@radandevist radandevist commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Closes #863

Implementer: Ox Alpha (stealth/ox-alpha via Nous Portal, max effort, jcode). Reviewer: pending adversarial review.

What this does

An ExternalStateStatus = 6 Unclassified dead-letter row used to have no way out: age retention deleted it blindly at 90 days even though nobody ever established whether its external effects exist. Two coordinated changes give every DLQ class either a resolution path or a visible reason to stay:

  1. Schema (db86cd814, prior commit): external_state_status enum + columns on job_dead_letter with CHECK constraints, partial index, immutable job_dead_letter_events evidence table, expand-only migration.
  2. Operator triage endpoint (3142223fc): POST /staff/dead-letter/{id}/resolve-unclassified (new jobs.resolve permission). An operator confirms the externally-referenced resource is absent; fail-closed contract below.
  3. Retention exemptions (efbc3051e): sweep skips statuses 1 Present / 6 Unclassified past the horizon; every run logs a structured SkippedCount so exempt rows cannot silently starve the sweep.
  4. Client regen (1cd4baf70): Kiota client picks up the new endpoint (staff/deadLetter/item/resolveUnclassified).

Fail-closed contract (endpoint)

  • Unknown or malformed id → 404 (string route param parsed in-handler; repo rule: no route constraints)
  • Row's current external state ≠ 6 Unclassified → 409 naming the actual state
  • Success stamps 4 Missing keeping the recorded prepared/expires bounds, appends exactly one immutable event (reason: operator_confirmed_absent, optional note ≤500 chars), and writes an audit entry (job.dead_letter.triage.resolved)
  • Double-resolve race: loser gets 409 Missing; still exactly one event row
  • 403 for staff without jobs.resolve, verified to mutate nothing

Retention semantics

Status 0 None sweeps exactly as before (strict < horizon unchanged, batching/SKIP LOCKED untouched). Present/Unclassified are held back; the structured log reports DeletedCount and SkippedCount (exempt rows beyond horizon) so starvation stays observable until #864/#865 land.

Paired proofs (TDD)

Retention exemption — RED without fix (reverted handler, tests kept)

Command: dotnet test --filter DeadLetterRetentionHandlerSpec.ItShouldExemptPresentAndUnclassifiedRowsFromAgeRetention|ItShouldReportSkippedExemptRowCountWhenSweepEncountersExemptRows
Result: Failed: 2, Passed: 0

Failed ...ItShouldExemptPresentAndUnclassifiedRowsFromAgeRetention
  Expected (verify.JobDeadLetter.AnyAsync(d => d.JobType == $"{marker}.none")) to be True because status 0 None stays plain age-retention eligible, but found False.
  [unmodified sweep had also deleted the Present and Unclassified rows]
Failed ...ItShouldReportSkippedExemptRowCountWhenSweepEncountersExemptRows
  Expected skipRecord not to be <null> because the sweep must report skipped exempt counts so starvation stays visible.

GREEN after the handler change: all Jobs specs Passed: 164, Failed: 0 (the same two facts now pass; None-row assertion correctly expects it swept).

Endpoint slice — RED without route registration (MapPost neutralized, fresh binary verified by DLL timestamp)

Command: dotnet test --filter ResolveDeadLetterUnclassifiedSpec|DeadLetterResolutionCatalogSpec
Result: Failed: 4, Passed: 4

Failed ...ItShouldReturnForbiddenForStaffWithoutPermission   (404 instead of 403)
Failed ...ItShouldReturnConflictWhenRowIsNotAwaitingTriage   (404 instead of 409)
Failed ...ItShouldLoseTheRaceCleanlyOnDoubleResolution       (404 instead of 200/409)
Failed ...ItShouldResolveAnUnclassifiedRowStampingMissingWithEventAndAudit (404 instead of 200)

Exactly the four facts that need the route fail; the catalog architecture pin and the two no-route-constraint 404 facts are route-independent and stay green. Restored registration → Passed: 8, Failed: 0.

Method note (honesty item): a first RED attempt passed 8/8 because MSBuild incremental compilation reused a stale DLL; caught it via DLL mtime vs edit time and re-ran with --no-incremental. The recorded RED above is from the verified-fresh binary.

Gates

  • Targeted: all 164 FullyQualifiedName~Jobs specs green (includes retention pair, endpoint facts, catalog pin); after the fixes below, Jobs | ServiceAttributeRegistration | HandlerScopeNamingGuard = 171/171 green
  • Full API integration suite (Testcontainers) run once at the end under heavy.sh: Passed 1907 / Failed 3 / Total 1910. All three failures are the boot-probe specs (SeederGateProbeSpec, AppRoleCompositionSpec, MasterKeyWitnessBootIntegrationSpec) whose spawned child processes exit during AppEnvironment.Initialize() with exactly Environment validation failed: TRUSTED_PROXY_CIDRS must be set explicitly for a production API role — this worktree has no .env.development and the main checkout's file predates that key; they fail before any Jobs-domain code executes and are green wherever a complete env file exists.
  • just generate-client regenerated apps/api/openapi.json + packages/client-ts cleanly
  • pnpm --filter front typecheck green against the new client
  • pnpm run lint:fix && pnpm run format:write clean (one unrelated pre-existing formatter nit in check-archive-records.ts reverted rather than bundled here)
  • pnpm test:ci-drift green (14/14)
  • Migration snapshot in sync (generated with the migration; expand-only, additive CHECKs/index)

Unverified / notes for the reviewer

  • Full-suite first pass surfaced two real gaps this slice had to fix, caught by repo architecture pins: HandlerScopeNamingGuardSpec required the ForStaff scope marker on a Handlers/Staff/ handler class (handler + wire types renamed ResolveDeadLetterUnclassified*ResolveDeadLetterUnclassifiedForStaff*; service-domain records keep the domain name), and ServiceAttributeRegistrationSpec's expected-services manifest needed the new (IJobDeadLetterService, JobDeadLetterService) entry.
  • Three full-suite failures are environmental, not from this branch's code: SeederGateProbeSpec, AppRoleCompositionSpec, MasterKeyWitnessBootIntegrationSpec spawn child processes whose boot fails at AppEnvironment.Initialize() ("Environment validation failed") because this worktree has no .env.development and the main checkout's file predates TRUSTED_PROXY_CIDRS. They fail before any Jobs-domain code executes; they are green in CI and in other lanes with complete env files.
  • Local e2e stack intentionally not run per the captain's verification policy (2026-08-23); CI runs front-e2e on this PR. No apps/front source files were touched (shared-ts i18n JSON + generated client only), so the full front vitest suite was not run locally either.
  • just ci-migration-expand-contract was not executed locally (DB-spinning); migration is expand-only by construction but CI owns that signal.
  • The endpoint was exercised through Testcontainers HTTP round-trips only, not against a live server.
  • The SkippedCount == 2 assertion pins the exact exempt population in the spec scenario; if a future change adds exempt states, DeadLetterResolutionCatalogSpec forces updating both the enum↔catalog coverage pin and the retention-exemption set together.
  • Worktree env quirks documented for future lanes: (a) generate-client needs TRUSTED_PROXY_CIDRS exported explicitly when .env.development predates that key; (b) MSBuild incremental compilation can silently reuse a stale DLL after out-of-band file edits — use --no-incremental when proving RED states by reverting production hunks.

Rebase (2026-08-25)

Rebased lane/wt-863 onto origin/develop (5a2b830) to clear the CONFLICTING state that blocked CI. Conflicts resolved keeping BOTH intents: develop's #1336/#864 untriaged-Missing hold AND this PR's #863 external-state resolution path.

Conflicts and decisions:

Gates after rebase: targeted DLQ specs 15/15; full API integration suite run ONCE under heavy.sh: 1932/1932 passed (this time including the three boot-probe specs that were environmental failures before, since .env.development now exists in the worktree); just ci-drift 7/7; pnpm --filter front typecheck green; pnpm format (read-only oxfmt check, same as CI) clean — an oxfmt normalization nit it auto-fixed on untouched packages/scripts-ts/src/check-archive-records.ts was reverted rather than bundled here.

Model: Ox Alpha via Nous Portal, max, jcode.

…fied operator triage, retention eligibility

K-1 of the #852 jobs design. Scope ruling: the external_state_* machinery is
absent from code (verified); build the minimal coherent vertical slice —
expand-only migration (columns, CHECKs, partial index, job_dead_letter_events
evidence table), ExternalStateStatus enum, retention exemption for
bytes-possible rows with skip-count reporting, staff resolve-unclassified
endpoint under a dedicated permission writing a detected_by='operator' event,
and an architecture pin tying every status class to its resolution path.

Design: .dump/design.md (tracked copy in docs/analysis/).
Expand-only migration AddJobDeadLetterExternalState:
- job_dead_letter gains external_state_status (int, default 0 = None,
  backfill value for existing rows) plus prepared_at/expires_at/
  expired_at bounds, guarded by three CHECK constraints
  (_status domain, _bounds nullability pairing, _expired_at iff status=2)
- partial index ix_job_dead_letter_external_state WHERE status <> 0
  serving the retention exemption predicate and future triage queues
- new append-only evidence table job_dead_letter_events (uuidv7 PK,
  FK CASCADE to job_dead_letter, event/detected_by/prior_status/
  new_status/details jsonb/occurred_at) with its covering index
- ExternalStateStatus enum (0 None .. 6 Unclassified) and
  JobDeadLetterEvents wire vocabulary constants

No behavior change yet: nothing writes the new columns.
…-1, #863)

POST /staff/dead-letter/{id}/resolve-unclassified lets an operator confirm an Unclassified dead letter's external effects are absent: the row stamps ExternalStateStatus.Missing keeping its recorded bounds, one immutable job_dead_letter_events row records reason operator_confirmed_absent plus the optional note, and an audit-log entry records who resolved what.

Fail-closed contract: unknown OR malformed id -> 404 (string route param parsed in-handler, no route constraints); row not at status 6 -> 409 naming the actual external state; double-resolve race loser -> 409 Missing. New jobs.resolve staff permission; response keys added EN+FR.
#863)

Retention sweep keeps external_state_status 1 Present (effects may still exist) and 6 Unclassified (awaiting operator triage via resolve-unclassified) beyond the JOB_DEAD_LETTER_RETENTION_DAYS horizon; status 0 None still sweeps normally. Each run logs SkippedCount, the number of exempt rows past the horizon, so a growing exempt population cannot silently starve the sweep.
… entry (#863)

Full-suite architecture pins caught two gaps in the new slice: HandlerScopeNamingGuardSpec requires a Staff/Tenant fragment on Handlers/Staff/ handler classes (handler and its wire types now carry the canonical ForStaff suffix; service-domain Args/Result records keep the domain-only name), and ServiceAttributeRegistrationSpec's expected-services manifest gains (IJobDeadLetterService, JobDeadLetterService).
@radandevist
radandevist merged commit f92db00 into develop Aug 25, 2026
28 of 29 checks passed
@radandevist
radandevist deleted the lane/wt-863 branch August 25, 2026 03:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

jobs: an absent 6 Unclassified DLQ row has no resolution path and is exempt from age retention forever

1 participant