jobs: give Unclassified DLQ rows a resolution path and retention exemptions (K-1) - #1345
Merged
Conversation
…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
force-pushed
the
lane/wt-863
branch
from
August 25, 2026 03:00
d996aa2 to
dd73963
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #863
Implementer: Ox Alpha (stealth/ox-alpha via Nous Portal, max effort, jcode). Reviewer: pending adversarial review.
What this does
An
ExternalStateStatus = 6 Unclassifieddead-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:db86cd814, prior commit):external_state_statusenum + columns onjob_dead_letterwith CHECK constraints, partial index, immutablejob_dead_letter_eventsevidence table, expand-only migration.3142223fc):POST /staff/dead-letter/{id}/resolve-unclassified(newjobs.resolvepermission). An operator confirms the externally-referenced resource is absent; fail-closed contract below.efbc3051e): sweep skips statuses 1 Present / 6 Unclassified past the horizon; every run logs a structuredSkippedCountso exempt rows cannot silently starve the sweep.1cd4baf70): Kiota client picks up the new endpoint (staff/deadLetter/item/resolveUnclassified).Fail-closed contract (endpoint)
404(string route param parsed in-handler; repo rule: no route constraints)409naming the actual state4 Missingkeeping 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)409 Missing; still exactly one event row403for staff withoutjobs.resolve, verified to mutate nothingRetention semantics
Status 0 None sweeps exactly as before (strict
<horizon unchanged, batching/SKIP LOCKED untouched). Present/Unclassified are held back; the structured log reportsDeletedCountandSkippedCount(exempt rows beyond horizon) so starvation stays observable until #864/#865 land.Paired proofs (TDD)
Retention exemption — RED without fix (reverted handler, tests kept)
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)
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
FullyQualifiedName~Jobsspecs green (includes retention pair, endpoint facts, catalog pin); after the fixes below,Jobs | ServiceAttributeRegistration | HandlerScopeNamingGuard= 171/171 greenheavy.sh: Passed 1907 / Failed 3 / Total 1910. All three failures are the boot-probe specs (SeederGateProbeSpec,AppRoleCompositionSpec,MasterKeyWitnessBootIntegrationSpec) whose spawned child processes exit duringAppEnvironment.Initialize()with exactlyEnvironment validation failed: TRUSTED_PROXY_CIDRS must be set explicitly for a production API role— this worktree has no.env.developmentand 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-clientregeneratedapps/api/openapi.json+packages/client-tscleanlypnpm --filter front typecheckgreen against the new clientpnpm run lint:fix && pnpm run format:writeclean (one unrelated pre-existing formatter nit incheck-archive-records.tsreverted rather than bundled here)pnpm test:ci-driftgreen (14/14)Unverified / notes for the reviewer
HandlerScopeNamingGuardSpecrequired theForStaffscope marker on aHandlers/Staff/handler class (handler + wire types renamedResolveDeadLetterUnclassified*→ResolveDeadLetterUnclassifiedForStaff*; service-domain records keep the domain name), andServiceAttributeRegistrationSpec's expected-services manifest needed the new(IJobDeadLetterService, JobDeadLetterService)entry.SeederGateProbeSpec,AppRoleCompositionSpec,MasterKeyWitnessBootIntegrationSpecspawn child processes whose boot fails atAppEnvironment.Initialize()("Environment validation failed") because this worktree has no.env.developmentand the main checkout's file predatesTRUSTED_PROXY_CIDRS. They fail before any Jobs-domain code executes; they are green in CI and in other lanes with complete env files.apps/frontsource 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-contractwas not executed locally (DB-spinning); migration is expand-only by construction but CI owns that signal.SkippedCount == 2assertion pins the exact exempt population in the spec scenario; if a future change adds exempt states,DeadLetterResolutionCatalogSpecforces updating both the enum↔catalog coverage pin and the retention-exemption set together.generate-clientneedsTRUSTED_PROXY_CIDRSexported explicitly when.env.developmentpredates that key; (b) MSBuild incremental compilation can silently reuse a stale DLL after out-of-band file edits — use--no-incrementalwhen proving RED states by reverting production hunks.Rebase (2026-08-25)
Rebased
lane/wt-863ontoorigin/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:
JobDeadLetterConfiguration.cs: kept BOTH partial indexes — develop'six_job_dead_letter_untriaged_missing(jobs:job-dead-letter-retentiondeletes4 Missingrows at 90 days, silently clearing their alert without triage #864) and oursix_job_dead_letter_external_state(jobs: an absent6 UnclassifiedDLQ row has no resolution path and is exempt from age retention forever #863).DeadLetterRetentionHandler): delete predicate now ANDs both exemptions (untriaged missing-anomaly prefix OR external_state Present/Unclassified holds a row); the pass logs both held-back counts (Helduntriaged missing rows +SkippedCountexempt rows). Spec file carries all four facts: develop's two jobs:job-dead-letter-retentiondeletes4 Missingrows at 90 days, silently clearing their alert without triage #864 facts (hold-untriaged / triaged-sweeps) plus our two jobs: an absent6 UnclassifiedDLQ row has no resolution path and is exempt from age retention forever #863 facts (Present/Unclassified exempt + skip-count report).response-message.en/fr.json): both key sets kept — develop'supload-budget-exhausted(chore(deps): close #880 follow-up — runtime guard + runbook record for the 2 moderate advisories #1331) alongside our threedead-letter-*keys.dotnet ef migrations add AddJobDeadLetterExternalState) on top of develop's snapshot →20260825023350_AddJobDeadLetterExternalState, autosquashed back into the schema commit. Verified: DesignerBuildTargetModelis byte-identical toAppDbContextModelSnapshot.BuildModelapart from the expected class/attribute boilerplate, anddotnet ef migrations has-pending-model-changesreports none. (Trap recorded: run it AFTER a fresh build — with--no-buildover a stale DLL it false-reports pending changes.)just generate-client, not by hand. Picks up theResolveDeadLetterUnclassifiedForStaffBodyrename from the final commit of this PR; squashed into the client commit. Nothing inapps/front/srcorpackages/shared-ts/srcreferences the renamed type.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.developmentnow exists in the worktree);just ci-drift7/7;pnpm --filter front typecheckgreen;pnpm format(read-only oxfmt check, same as CI) clean — an oxfmt normalization nit it auto-fixed on untouchedpackages/scripts-ts/src/check-archive-records.tswas reverted rather than bundled here.Model: Ox Alpha via Nous Portal, max, jcode.