Skip to content

Phase 81: Per-Stage Failure Persistence & Retry Paths#226

Merged
SimplicityGuy merged 54 commits into
mainfrom
SimplicityGuy/phase-81
Jul 9, 2026
Merged

Phase 81: Per-Stage Failure Persistence & Retry Paths#226
SimplicityGuy merged 54 commits into
mainfrom
SimplicityGuy/phase-81

Conversation

@SimplicityGuy

Copy link
Copy Markdown
Owner

Summary

Phase 81: Per-Stage Failure Persistence & Retry Paths
Goal: Make all three enrich stages persist a durable failure marker and gain a retry path — closing the latent bug where a failed metadata extraction records nothing and becomes invisible-and-permanently-ineligible.
Status: Verified ✓ (4/4 must-haves)

Before this phase, report_metadata_failed persisted nothing. A terminally-failed metadata extraction left no row, so derivation could not see it, counts could not report it, and no operator surface could retry it — the file was invisible and permanently ineligible, blocking it from ever reaching propose. Phase 81 closes that (gap G-01, CRITICAL) and brings the three enrich stages onto one coherent failure model.

analyze and metadata now stamp a durable failed_at + error_message marker via pg_insert(...).on_conflict_do_update, and clear it unconditionally on a later success. analyze's writer dual-writes the marker alongside the legacy files.state = ANALYSIS_FAILED write in one transaction, so recovery can derive from the marker while the three live state readers keep working until their cutover. fingerprint is deliberately left alone — it already persists fingerprint_results.status='failed' per engine, and this phase adds only characterization tests plus docstrings locking that asymmetry in (FAIL-04 asks for reused, not re-invented). Migration 033 makes the analyze invariant structural with a analysis_completed_at XOR failed_at CHECK, preceded in the same upgrade() by the set-based cleanup UPDATE that makes the live corpus satisfy it. Finally, POST /pipeline/metadata-failed/retry gives the operator a bulk retry path that re-enqueues every failed-metadata file and, per D-11, leaves the failure row in place until a real success clears it.

The two terminality axes are now named tables in the DB-free enums/stage.pyFAILURE_IS_TERMINAL and ELIGIBLE_AFTER_FAILURE — so no second reader ever re-derives the analyze/fingerprint asymmetry by hand. domain_completed() ships with its SQL twin domain_completed_clause() in the same phase, drift-locked by a parametrized equivalence test.

Changes

Plan 81-01: DB-free terminality tables + domain_completed twin

Two orthogonal per-stage tables (recovery terminality vs auto-retry eligibility), a pure domain_completed() predicate, its ColumnElement SQL twin, and a semantics-preserving eligible() refactor.

Key files: src/phaze/enums/stage.py, src/phaze/services/stage_status.py, tests/integration/test_stage_status_equivalence.py

Plan 81-02: Migration 033 — mixed-row cleanup then XOR CHECK

ck_analysis_analysis_completed_xor_failed enforced at the DB and mirrored into AnalysisResult.__table_args__. The D-09 cleanup UPDATE runs before create_check_constraint so Phase 77's unguarded backfill rows don't abort the migration; done wins (failed_at cleared, analysis_completed_at retained). The destructive Phase-90 migration is renumbered 033 → 034 in forward-looking docs.

Key files: alembic/versions/033_add_analysis_completed_xor_failed.py, src/phaze/models/analysis.py, tests/integration/test_migrations/test_migration_033_additive_check.py

Plan 81-03: Metadata failure persistence (FAIL-02, the latent bug)

report_metadata_failed upserts a durable failure row instead of recording nothing. The body is optional (MetadataFailurePayload | None = None), so an old bodyless agent image still gets a 200 and still clears its ledger row (D-10 version-skew guard). put_metadata clears the marker unconditionally on success, including the empty-body branch (D-13).

Key files: src/phaze/routers/agent_metadata.py, src/phaze/schemas/agent_metadata.py, src/phaze/services/agent_client.py, src/phaze/tasks/metadata_extraction.py

Plan 81-04: Fingerprint asymmetry, locked by characterization tests

No new writer. Regression tests assert report_fingerprint_failed persists no row, never writes a synthetic engine='_task' sentinel (which would poison the per-engine joins), and that a status='failed' engine row keeps the file eligible(fingerprint).

Key files: src/phaze/routers/agent_fingerprint.py, tests/fingerprint/routers/test_agent_fingerprint_failure.py

Plan 81-05: Analyze failure dual-write

report_analysis_failed stamps the marker and the legacy state write in one transaction. It uses ON CONFLICT DO UPDATE rather than a bare UPDATE, because a pure analyze failure never wrote an analysis row at all — an UPDATE would silently no-op and lose the marker. put_analysis clears failed_at/error_message outside exclude_unset, so a real success both restores the D-06 invariant and satisfies the new CHECK.

Key files: src/phaze/routers/agent_analysis.py, tests/analyze/routers/test_agent_analysis_failure.py

Plan 81-06: Metadata operator retry path (FAIL-03)

POST /pipeline/metadata-failed/retry mirrors retry_analysis_failed's Phase-30 guard ordering (resolve the per-agent queue once → NoActiveAgentError guard → enqueue with the complete ExtractMetadataPayload), minus the state flip — metadata has no terminal FileState. Backed by get_metadata_failed_files, a correlated exists() on metadata.failed_at IS NOT NULL.

Key files: src/phaze/routers/pipeline.py, src/phaze/services/pipeline.py, src/phaze/templates/pipeline/partials/metadata_retry_response.html, tests/integration/routers/test_pipeline_metadata_retry.py

Requirements Addressed

Req Description Plans
FAIL-01 analyze failures persist a durable failure marker with an error reason, backfilled from existing ANALYSIS_FAILED rows 81-01, 81-02, 81-05
FAIL-02 metadata failures persist a durable failure marker — report_metadata_failed records the failure instead of nothing (closes the latent bug where terminally-failed metadata is invisible everywhere) 81-03
FAIL-03 A terminally-failed metadata file has a retry path, so a metadata failure is never a permanent dead-end that blocks the file from ever reaching propose (closes gap G-01, CRITICAL) 81-06
FAIL-04 fingerprint failure continues to persist via fingerprint_results.status='failed' (reused, not re-invented) and stays auto-retryable 81-01, 81-04

4 requirements, 0 orphans.

Verification

  • Automated verification: PASSED — 4/4 must-haves verified (81-VERIFICATION.md), each traced to source and to independently re-run tests rather than cited from SUMMARY.
  • Code review (81-REVIEW.md) — 20 files, 2 criticals found and fixed before close: CR-01 retry_analysis_failed did not clear analysis.failed_at (1ff92265); CR-02 the domain_completed twins rejected downstream stages asymmetrically (a6398d33).
  • Security audit (81-SECURITY.md) — 24/24 threats closed, register authored at plan time. Two remediated during the audit: T-81-03-04/T-81-05-03 (1d6af9f7), where a NUL in an agent's error text aborted the transaction that also clears the scheduling ledger, stranding the file in an unbounded recovery loop. The audit also surfaced WR-03 (feaebc48): eligible() compared Status by identity, so a raw-string status map — exactly what a SQL round-trip yields — reported a terminally-failed analyze as eligible, the same class as the 44.5K-job over-enqueue incident.
  • Nyquist validation (81-VALIDATION.md) — 16 task rows audited, all green in bucket isolation. One gap found and filled: 81-SECURITY.md closed T-81-03-04/T-81-05-03 on two limbs, but the max_length=2000 limb had no test in either payload — deleting the bound would have left the suite green while re-opening both threats (365a467b).

Test evidence (every bucket run in isolation, not only as part of the full suite):

Bucket Result
shared 997 passed
metadata 77 passed
fingerprint 83 passed
analyze 519 passed
integration 155 passed
docs-drift 10 passed

uv run mypy clean across all six touched source modules.

Key Decisions

  • D-04 / D-09 — done wins. Migration 033's cleanup UPDATE runs before create_check_constraint, clearing failed_at and retaining analysis_completed_at on mixed rows. Matches Phase 78's _analyze_status done-over-failed precedence. A validating DDL constraint is preceded, in the same upgrade(), by the UPDATE that makes the existing corpus satisfy it.
  • D-06 — the invariant is structural. No analysis row may ever carry both analysis_completed_at and failed_at. Enforced by CHECK, mirrored in the ORM, and satisfied by both writers.
  • D-10 — version-skew safety. The failure endpoint's body is optional. A control plane at Phase 81 with an agent still on the prior image must return 200 and still clear the ledger, or the file strands.
  • D-11 — retry does not clear the marker. retry_metadata_failed re-enqueues without touching the failure row; only put_metadata's clear-on-success wipes it. A retry that hasn't succeeded yet must still read as failed.
  • D-18 — fingerprint is reused, not re-invented. report_fingerprint_failed persists no row, by design; FAILURE_IS_TERMINAL[fingerprint] = False makes auto-retry intentional rather than accidental.
  • Two orthogonal axes, named once. FAILURE_IS_TERMINAL (does recovery consider this stage finished?) and ELIGIBLE_AFTER_FAILURE (may it auto-retry?) are separate tables. Collapsing them is what let the analyze/fingerprint asymmetry get re-derived inconsistently by different readers.
  • Python predicate + SQL twin ship together. domain_completed() and domain_completed_clause() land in the same phase, drift-locked by a parametrized equivalence test — closing the window Phase 78's D-04 left open.

Known Open Items (deliberate, tracked)

Two review warnings are carried forward in deferred-items.md rather than fixed here. Both are real, both are scoped to Phase 80's reader cutover, and neither blocks this phase's goal:

  • WR-01report_metadata_failed's conflict branch does not null pre-existing payload columns, so a file with real tags that later fails extraction derives FAILED while still holding usable metadata.
  • WR-02domain_completed_clause has no inflight disjunct while the Python twin ranks IN_FLIGHT above FAILED, so the twins diverge on in_flight ∧ failed rows — a cell that FAIL-03's new retry path makes reachable. The equivalence test's DOMAIN_COMPLETED_CASES deliberately excludes the in-flight seeds, so it will not catch this. Recorded under Known Coverage Boundaries in 81-VALIDATION.md so a green suite is not mistaken for a stronger guarantee than it makes.

Two manual verifications remain (both need infrastructure a test DB can't reproduce): the bodyless-POST path against a genuinely older deployed agent image, and migration 033's cleanup against a restored production snapshot.

Note on Diff Scope

This branch also carries c3cf84b7, a planning-only ROADMAP edit rewiring Phase 80/83 depends_on to break a 80 → 83 → 82 → 80 dependency cycle. It touches no source. The .planning/ tree accounts for 26 of the 51 changed files; source + migration + template changes total 15 files (+521/−70).

🤖 Generated with Claude Code

https://claude.ai/code/session_01LM2oA27zEky9dq3PVjJteL

SimplicityGuy and others added 30 commits July 8, 2026 19:58
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LM2oA27zEky9dq3PVjJteL
Per-Stage Failure Persistence & Retry Paths. Wave 1: enums/domain_completed
twin, migration 033, metadata failure writer, fingerprint regression. Wave 2:
analyze dual-write (depends 033), metadata retry endpoint (depends metadata writer).
All 18 CONTEXT decisions (D-01..D-18) cited; FAIL-01..04 covered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LM2oA27zEky9dq3PVjJteL
…ulate validation map

- 81-02 Task 3: rewrite as a forward-looking-vs-historical classification rule
  (renumber destructive 033->034 only in ROADMAP/REQUIREMENTS(MIG-04)/design/
  PROJECT.md:22/STATE.md:67; deliberately leave phases 77/79, research snapshot,
  PROJECT.md:113/115 dated records; PROJECT.md:423 gets a clarifying parenthetical).
  Add PROJECT.md + STATE.md to files_modified. Note docs-drift is necessary-not-
  sufficient for the renumber. Frontmatter must_haves.truths / threat_model /
  Tasks 1-2 untouched.
- 81-RESEARCH.md: mark Open Questions (RESOLVED) with per-entry citations.
- 81-VALIDATION.md: populate Per-Task Verification Map (17 rows, 81-01..81-06),
  set nyquist_compliant: true (wave_0_complete + Approval left for execute-phase).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LM2oA27zEky9dq3PVjJteL
- Mirror AnalysisFailurePayload shape (Literal reason + bounded error)
- extra='forbid' keeps unknown-field body a 422 (AUTH-01)
- Optional triage body for report_metadata_failed (FAIL-02/D-10)
…y (FAIL-04)

- report_fingerprint_failed persists NO fingerprint_results row (D-18); count
  unchanged before/after, ledger is the sole persistent effect
- a status='failed' per-engine row keeps eligible(FINGERPRINT) True (ELIG-04)
- no synthetic engine='_task' sentinel row is ever written; the aliased
  per-engine joins (pipeline.py:939-940) stay unpoisoned (T-81-04-01)
- report_metadata_failed writes a metadata row (failed_at set, payload NULL)
  via optional body (bodyless old-agent POST still 200 + ledger cleared, D-10)
- put_metadata unconditionally clears failed_at/error_message on success,
  including the empty-body branch (D-13)
- agent_client.report_metadata_failed widened to send optional payload
- metadata task composes MetadataFailurePayload(reason=error) on terminal ack
…-18)

- report_fingerprint_failed: persists NO fingerprint_results row by design; a
  synthetic engine='_task' row would poison the aliased per-engine joins
  (pipeline.py:939-940) + _trackid_engine_badge (:864); durable marker is the
  put_fingerprint status='failed' row
- put_fingerprint: its status='failed' row is the auto-retryable marker
  (FAILURE_IS_TERMINAL[fingerprint] = False, ELIG-04)
- docstrings only; no behavior change
…cess

- bodyless POST persists failed_at row (payload NULL) -> resolve_status FAILED, ledger cleared
- with-body POST populates error_message as '<reason>: <error>'
- unknown body field -> 422 (extra='forbid')
- empty-body + field success PUT after failure clear the marker (D-13)
- update terminal-ack task tests for the widened report_metadata_failed payload arg
…domain_completed(), refactor eligible()

- Two orthogonal DB-free axes (D-15): FAILURE_IS_TERMINAL (recovery) +
  ELIGIBLE_AFTER_FAILURE (eligibility) for the three enrich stages.
- Pure domain_completed(status_map, stage) helper (D-17 Python twin).
- Collapse eligible()'s three enrich branches into one ELIGIBLE_AFTER_FAILURE-driven
  expression, semantics-preserving (D-16); ELIG-01..04 pass unchanged.
- Module stays stdlib-only / DB-free (T-78-01).
- SQL twin of enums.stage.domain_completed (D-17): or_(done_clause, failed_clause)
  for terminal stages, bare done_clause for fingerprint (failure not terminal).
- Reuses the LOCKED done_clause/failed_clause predicates verbatim (no new CASE).
- Imports FAILURE_IS_TERMINAL from phaze.enums.stage.
- Add DOMAIN_COMPLETED_CASES (enrich stages, non-in-flight) asserting
  domain_completed_clause (SQL) == domain_completed (Python) == expected per cell.
- Twins are ledger-agnostic by design; in_flight precedence is layered at the
  resolve/eligible level, so cells exclude the *_inflight seeds (documented).
- Existing CASES + test_sql_equals_python left unchanged.
- Log unrelated migration-019 full-suite isolation flake to deferred-items.
- New alembic/versions/033_add_analysis_completed_xor_failed.py (revision 033, down_revision 032)
- upgrade(): D-09 mixed-row cleanup UPDATE (clears failed_at, keeps analysis_completed_at -- done wins, D-04) BEFORE create_check_constraint, so the CHECK cannot abort on rows 032's unguarded backfill produced
- downgrade(): DDL-only drop_constraint; the cleanup UPDATE is not reversed (016/032 precedent)
- Mirror the CHECK into AnalysisResult.__table_args__ with the bare name so alembic autogenerate stays empty (D-06); renders as ck_analysis_analysis_completed_xor_failed
- New test_migration_033_additive_check.py: seeds at 031, lets 032's REAL unguarded backfill manufacture the mixed row, then upgrades to 033
- Asserts D-09 ordering (upgrade succeeds at all) and D-04 done-wins (failed_at nulled, analysis_completed_at retained); failed-only and done-only rows untouched
- Asserts rendered name ck_analysis_analysis_completed_xor_failed, ORM/migration predicate parity, empty autogenerate diff, CHECK rejects a newly-mixed row, down/up round-trip
- Source-order gate matches the op.* call sites; collapse _CLEANUP_MIXED_ROWS to a single-line SQL literal so the UPDATE precedes create_check_constraint by line number too
Phase 81 claims alembic revision 033 (additive XOR CHECK), so the forward-looking
destructive migration references move to 034 (D-08).

- ROADMAP.md: milestone-shape line, Phase 79 ordering, Phase 90 title + summary-table row + detail block (the one-transaction line and the downgrade() line -- the two 81-RESEARCH found missing from D-08's list)
- REQUIREMENTS.md: MIG-04 only (MIG-02 carries no literal 033)
- PARALLEL-ENRICH-DAG-DESIGN.md: the destructive step + the drain-before line
- PROJECT.md:22 (current two-step design line); PROJECT.md:423 keeps its dated 033 and gains a clarifying parenthetical
- STATE.md: both destructive-migration references on the roadmap-created entry

Deliberately unchanged (dated historical records): .planning/phases/77-*, .planning/phases/79-*,
.planning/research/*, PROJECT.md:113/115. ROADMAP.md:378 names Phase 81's OWN additive 033.
- 81-02-SUMMARY.md: 3 tasks, 5 deviations, self-check PASSED
- STATE.md: position (wave 1 complete, plan 5 of 6), 2 decisions, performance metric
- ROADMAP.md: Phase 81 progress 4/6 In Progress; wave-1 plans checked off

REQUIREMENTS.md deliberately NOT touched: requirements.mark-complete FAIL-01 trips the
docs-drift traceability guard (requirement completion is phase-gated, and Phase 81's
wave 2 is outstanding). FAIL-01 stays Pending until the phase passes verification.
SimplicityGuy and others added 24 commits July 8, 2026 23:10
Mark 81-01/81-03/81-04 complete after post-merge gate (mypy clean; shared 939,
analyze 512, agents 441, integration 146, fingerprint 83, metadata 74 all green).

Retract 81-01's deferred-items claim that test_migration_019_dedupe is a
bucket-isolation flake: it fails in isolation too. Real cause is
MIGRATIONS_TEST_DATABASE_URL defaulting to port 5432 while just test-db
provisions 5433.
…failed_at

- Reuses the failed_clause(Stage.METADATA) shape to select every file with a
  persisted metadata failure row (failed_at IS NOT NULL, payload NULL)
- Pure ORM / bound params, no f-string SQL (T-42-03)
- D-11: returns the retry set; the row is left in place by the retry path
- Re-enqueues every metadata.failed_at IS NOT NULL file via the shared
  _enqueue_extraction_jobs producer (COMPLETE ExtractMetadataPayload, not a
  dead-lettering file_id-only enqueue) + central extract_file_metadata:<id> key
- Mirrors retry_analysis_failed's Phase-30 guard: resolve queue once, catch
  NoActiveAgentError and return without enqueue/mutation (no default-queue
  fallthrough)
- D-11: NO f.state flip (metadata has no terminal FileState); the failure row is
  left in place, put_metadata clears failed_at only when real metadata lands
- Stage-labelled metadata_retry_response.html fragment (not analyze-worded)
…ysis_failed

- upsert analysis.failed_at + error_message via ON CONFLICT (file_id) DO UPDATE
- clear analysis_completed_at so the migration-033 XOR CHECK never sees a mixed row (D-06)
- keep the files.state = ANALYSIS_FAILED write (D-05 dual-write, three live readers)
- all in one transaction, ordered marker -> state -> ledger -> staged-delete
- success-path on_conflict set_ unconditionally sets failed_at=None, error_message=None
- outside exclude_unset (the wire body never carries them)
- lets the completion branch stamp analysis_completed_at without violating the D-06 XOR CHECK
- re-enqueues all failed-metadata files on the per-agent meta lane with the
  COMPLETE ExtractMetadataPayload (never the default queue)
- D-11: failure rows survive a not-yet-succeeded retry (failed_at NOT NULL)
- Phase-30 guard: no active agent -> zero enqueues, no mutation, no fallthrough
- get_metadata_failed_files returns exactly the failed-metadata set
- plain operator client fixture; 5 tests pass in integration-bucket isolation
…OR CHECK, clear-on-success

- failure with NO prior analysis row inserts the marker via ON CONFLICT DO UPDATE (OQ2)
- failure on a done row clears analysis_completed_at (D-06 XOR CHECK holds)
- success after failure clears failed_at/error_message and stamps completed_at (D-13)
- every assertion re-checks the no-mixed-row invariant
…ically (CR-02)

domain_completed_clause subscripted FAILURE_IS_TERMINAL unconditionally, raising a bare
KeyError for tracklist/propose/review/apply, while the Python twin short-circuited on
Status.DONE and returned True for those same stages -- a silent divergence on every
non-failed downstream row, in the very predicate the drift-lock exists to protect.

Both twins now raise an identical, actionable ValueError naming the supported stages.
Fail loud rather than default: reenqueue.py already classifies the four downstream stages
'live-keys-only ... no domain predicate', so inventing a terminality value for them is the
kind of guess that becomes an over-enqueue incident. 17 DB-free contract tests pin the
exclusion as explicit, total, and symmetric (12 fail without the guards).
FAIL-01 made analysis.failed_at a durable twin of files.state=ANALYSIS_FAILED, but
retry_analysis_failed predates the marker and flipped only the state half. After one click
of the operator retry button every retried file sat at state='fingerprinted' AND
analysis.failed_at IS NOT NULL -- a row the Phase 79 shadow-compare gate reads as divergent
and domain_completed(ANALYZE) reads as terminal, so Phase 80 recovery would skip exactly the
files just re-enqueued. put_analysis only clears the marker on a successful re-analysis, so a
job that never lands strands it permanently.

Clear both halves in the same transaction. Safe by construction: migration 033's
analysis_completed_xor_failed CHECK guarantees analysis_completed_at IS NULL on a failed row,
so the cleared row derives not_started. Unlike metadata (D-11), analyze carries completion in
a column rather than by row presence, so clearing cannot make it read DONE.

4 integration tests; 3 fail without the fix. The Phase-30 no-active-agent guard still
mutates nothing.
…04, T-81-05-03)

Both threats are titled 'PG-invalid free text (NUL/surrogates) OR oversized error', but only the
oversized limb was mitigated. NUL passes pydantic validation (max_length bounds length only; lone
surrogates are separately rejected as string_unicode), then Postgres rejects the write with
CharacterNotInRepertoireError. The marker upsert and the scheduling-ledger clear share one
transaction, so the abort rolls BOTH back: the ledger row survives, recovery re-enqueues the file,
and it fails identically on the same exception text forever -- the unbounded-recovery-loop outcome
T-81-03-03 exists to prevent, reached through a different door. Same class as the v4.0.5 incident.

Extract the already-tested sanitizer out of services/metadata.py (which imports mutagen) into a
stdlib-only services/pg_text.py so the control-plane routers can reuse it without dragging the
audio-tag stack into the API import graph. Apply it before truncation at both persist sites --
stripping can only shorten, so the 2000-char bound still holds.

Verified against real Postgres: both new end-to-end tests fail with
'invalid byte sequence for encoding UTF8: 0x00' when the sanitizer is reverted, and assert the
ledger actually clears -- not merely that a string was stripped.
…mpleted (WR-03)

Status is a StrEnum, so a status_map that came back through a SQL or JSON round-trip carries raw
strs -- and stage_status_case emits exactly Status.X.value. Under 'is', an equal-but-distinct string
matched nothing: eligible({ANALYZE: 'failed'}, ANALYZE) returned True, reporting a terminally-failed
analyze as eligible. That is the 44.5K over-enqueue class ELIG-03 exists to guard. Python already
warned: SyntaxWarning: "is" with 'str' literal.

Coerce through Status(...) at the boundary, then compare by value. An unrecognised status now raises
instead of silently reading as NOT_STARTED. Both twins' downstream-stage guards also stop assuming a
Stage enum -- a raw-str stage hashes equal to its member and reached .value, raising AttributeError
instead of the intended ValueError.

Not reachable in production today (no caller outside enums/stage.py), but Phase 80's reader cutover
builds its recovery derivation directly on these predicates. 21 new cells; 12 fail without the fix.

Surfaced by the Phase 81 security audit. WR-03 was in 81-REVIEW.md but I omitted it from
deferred-items.md when recording the review outcome.
…3-04, T-81-05-03)

81-SECURITY.md closes T-81-03-04 and T-81-05-03 on two limbs: PG-invalid
characters and oversized free text bounded by max_length=2000. Only the
PG-invalid limb had tests. Deleting max_length from either failure payload
would have left the whole suite green while re-opening both threats.

Adds a reject/accept pair per payload, pinning the boundary at exactly
2000/2001 so the guard also fails if the bound is lowered, not just removed.
The reject cases assert no row is persisted, and for analyze that files.state
never flips to ANALYSIS_FAILED.

Found by /gsd:validate-phase 81 — the sole MISSING row of 16 audited.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LM2oA27zEky9dq3PVjJteL
@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@SimplicityGuy SimplicityGuy merged commit 191a8c7 into main Jul 9, 2026
34 checks passed
@SimplicityGuy SimplicityGuy deleted the SimplicityGuy/phase-81 branch July 9, 2026 17:25
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.

1 participant