fix: one canonical persisted shape for ParticipantStatus - #2278
fix: one canonical persisted shape for ParticipantStatus#2278sei-ahouseholder wants to merge 6 commits into
Conversation
ParticipantStatus existed in two incompatible shapes writing to the same DataLayer row: core nests `rm: RmDimension`, wire carries a flat `rm_state`. Whichever class read the row back decided what the data meant. Three silent failures are now loud: - Core `CaseParticipant` dropped wire-spelled (camelCase) keys as unknown, then `_init_participant_status_if_empty` re-seeded one status at RM.START — a START->RECEIVED ladder silently became START. Now raises. - `Record.from_obj` only rejected `as_`-prefixed `type_`, but wire `type_` values are bare, so wire-shaped rows were persisted into core-typed tables. `CaseParticipant`/`ParticipantStatus` are now normalised via `to_core()` at the persistence boundary. - Readers doing `getattr(status, "rm", None)` got None on a wire-shaped status and took a wrong branch, substituting RM.START or skipping validation entirely. Now raises / returns Status.FAILURE per ARCH-15-001..004. Also fixes #2264 (the RM.START and None substitution sites). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- A pytest run killed by the 5s per-test timeout is indistinguishable from a passing one under the mandated 'pytest | tail -5' command: the pipeline exit code is tail's, and the faulthandler dump displaces the summary line. - devlog accumulation and the hardcoded /app/devlogs DEVLOGS_DIR default obstruct the clean-base proof the completeness doctrine requires (#2273). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sei-ahouseholder
left a comment
There was a problem hiding this comment.
PR Triage: #2278 — fix: one canonical persisted shape for ParticipantStatus
Linked issues: #2232 (ParticipantStatus has two persisted shapes), #2264 (silent None from status.rm.state)
Changed files: 20 files — core/models, core/behaviors, core/use_cases, adapters/driven, plan/incoming/learnings
CI status: ❌ failing
Merge state: ✅ mergeable (UNSTABLE)
Base branch: main
Needs integration tests: yes
Findings
| # | Phase | Severity | Description | Outcome |
|---|---|---|---|---|
| phase5-announce-path-wire-shape-raise-0 | spec-conformance | ❌ FAIL | New raising reader fires on wire-shaped participants embedded in inbound Announce, killing AnnounceVulnerabilityCaseReceivedBT (13 failures vs 0 on main); persisted core-shaped participant rows drop 93/1191 → 0/909 |
fix-now |
| phase8-valueerror-swallowed-in-inbox-0 | code-review | ❌ FAIL | except ValueError: pass in _inbox.py:195 now also swallows the new normalization failure — row silently not stored, never logged, indistinguishable from a duplicate |
fix-now |
| phase10-no-test-for-wire-incoming-regress-check-0 | test-coverage | ❌ FAIL | No test covers _would_regress_participant with an existing stored participant AND a wire-shaped incoming one — why the regression shipped green |
fix-now |
| phase9-domain-validation-note-contradicted-0 | notes-currency | ❌ FAIL | notes/domain-validation.md still directs helpers to core/models/_helpers.py, which this PR proved unusable, and never mentions the new canonical readers |
fix-now |
| phase5-normalize-top-level-only-0 | spec-conformance | _normalize_to_core inspects only the top-level object, so a nested wire-shaped status still persists flat rm_state — the issue's "Done when" is not met |
fix-now | |
| phase8-wire-spelled-guard-one-level-deep-0 | code-review | Docstrings claim core types carry no alias_generator=to_camel, but sibling ParticipantStatus does (#1991) — a rmState status is silently absorbed through the "loud" parent |
fix-now | |
| phase10-no-ratchet-for-normalize-set-0 | test-coverage | _NORMALIZE_WIRE_TO_CORE is documented as grow-only with no ratchet test; the read side has one (DL-05-004) |
fix-now | |
| phase8-normalize-bypassed-by-crud-write-path-0 | code-review | Invariant enforced at 1 of 3 write entry points — crud.create/crud.update bypass Record.from_obj, and CreateObject/UpdateObject build records from raw dicts |
new-issue-no-ask | |
| phase8-vultronvalidationerror-escapes-read-ladder-0 | code-review | VultronValidationError is not a ValueError, so it escapes every DL read-path except clause — the DL-05 fallback ladder is now un-closable for this error class |
fix-now | |
| phase8-unconverted-degraded-fallbacks-0 | code-review | The identical silent-degrade defect remains 4 lines below the fixed one (vfd in common.py:218-222) and in use_cases/_helpers.py:449-450 |
fix-now | |
| phase8-participant-status-summary-blank-0 | code-review | participant_status_summary reads only flat rm_state, so the save-time debug line now always reports rm=None |
fix-now | |
| phase7-any-type-violates-cs-11-001-0 | agents-md-compliance | New Any params where object suffices (CS-11-001); tuple[RM, ...] return makes both unpack sites unverifiable |
fix-now | |
| phase5-arch-15-004-misattributed-0 | spec-conformance | ARCH-15-004 (canonical helper location) cited for BT FAILURE semantics; correct IDs are ARCH-15-001/002 | fix-now | |
| phase7-case-participant-over-500-lines-0 | agents-md-compliance | case_participant.py at 506 lines exceeds the CS-18-001 ceiling with no tracking issue |
fix-now | |
| phase6-no-adr-for-persistence-boundary-0 | adr-check | Alternatives weighed and rejected (MS-11-002) + persistence shape change, but no ADR | fix-now | |
| phase9-datalayer-design-note-stale-0 | notes-currency | notes/datalayer-design.md never mentions the new write-side canonical-shape rule |
fix-now | |
| phase3-verification-omits-demo-ci-exception-0 | pr-body-format | Verification section omits the new Demo Integration failures this branch introduces | fix-now | |
| phase10-test-gaps-in-new-tests-0 | test-coverage | One new test lacks match= and cannot distinguish its two raise branches; to_core is None branch untested |
fix-now | |
| phase10-wire-spelled-keys-assumption-unpinned-0 | test-coverage | _WIRE_SPELLED_KEYS computed once from the base class and shared by 8 subclasses, with no test pinning the "subclasses add no fields" assumption |
fix-now |
Total: 4 FAIL · 15 IMPROVE · 0 NEW-ISSUE
Headline
The PR closes the duality on the write path but the same change breaks the read path at wire ingress. _store_embedded_participants → _would_regress_participant → _participant_rm_state(incoming) runs on participants embedded in an inbound AS2 Announce(VulnerabilityCase) — legitimate wire data, not a persisted row — so the new raise aborts the received-side BT. Measured on the fcvcv demo:
| main | #2278 | |
|---|---|---|
AnnounceVulnerabilityCaseReceivedBT did not succeed |
0 | 13 |
VultronValidationError |
0 | 26 |
| participant reads that are core-shaped | 93 / 1191 | 0 / 909 |
Because the raise pre-empts dl.save(participant_ref), participants are never stored core-shaped at all — the PR makes persisted state strictly worse in that demo. Issue #2232 prescribed normalising at the wire→core ingress boundary; the branch chose the persistence boundary instead (recorded in 20260812-normalize-at-persistence-not-wire-ingress.md), which is a defensible call but leaves ingress readers exposed.
Pre-existing on main and not attributed to this PR: the engage-case TransitionParticipantRMtoAccepted → HTTP 422 failure, the red fvcv-handoff Invariant Harness, and the test/ci/invariants validate_report failure (#2273).
Triage artifact: .claude/pr-2278-triage.json
Next step: /pr-execute (running now as part of /pr-ship).
- phase8-code-review — project embedded wire participants to core in received/case/_helpers.py instead of letting the strict readers raise into the behavior tree; an unprojectable participant is logged and skipped - phase8-code-review — _normalize_to_core() now projects direct children, so a wire CaseParticipant nested in a core VulnerabilityCase is persisted core-shaped - phase8-code-review — projection failures raise VultronValidationError, not ValueError, so they stay distinguishable from crud.create()'s duplicate-row ValueError; the FastAPI inbox logs the two at different levels - phase8-code-review — VultronValidationError added to the DataLayer read-path handlers that previously only caught ValidationError/ValueError - phase5-missing-vfd-reader — add participant_status_vfd_state(), the canonical VFD counterpart, and use it in resolve_participant_state_from_dl - phase5-remaining-degrade-sites — replace initial-state substitution in use_cases/_helpers.py, case/nodes/leave.py, sync/nodes/fanout.py and sync/nodes/effects.py with the canonical readers - phase7-duplicated-shape-guard — extract the camelCase rejection into models/_wire_spelling.py, computed per exact class so role subclasses guard their own fields - phase9-notes-not-updated — document the shape guards in notes/domain-validation.md and the write-path normalisation in notes/datalayer-design.md - phase6-missing-adr — add ADR-0062 for normalising at both ingress and the persistence boundary - phase10-test-coverage — 48 new tests across three new files: the wire-spelling guard, the _NORMALIZE_WIRE_TO_CORE ratchet, and participant_status_summary (whose n_statuses=0 branch was unreachable because `or` treated [] as absent) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…status-shape-duality
`_from_row` caught only `ValidationError` around `core_cls.model_validate`, but `VultronValidationError` is not a `ValueError` subclass — so the wire-spelled-key shape guard added for #2232 escaped the fallback ladder instead of being handled like every other shape mismatch (DL-05-002). When a row's stored `type_` *does* have a core counterpart and core validation fails with `VultronValidationError`, the row is a wire-spelled copy of a core type: reconstruct it through the wire vocabulary and then project it back with `to_core()`. Handing the wire object back unprojected is what made `resolve_case` raise "Expected VulnerabilityCase, got as_VulnerabilityCase" — a 422 on the finder at Phase 3 of the fcv-reject demo, which passes on main. The plain-`ValidationError` branch keeps its existing un-projected behaviour: those rows (e.g. `as_EmbargoEvent`, which lacks `context`) are what the KNOWN_WIRE_ESCAPES ratchet measures, and projecting them would dehydrate inline nested objects that callers still expect inline. Projection failure returns the wire object rather than `None`, so the worst case is exactly pre-#2232 behaviour, and both fallbacks now log the row id, type, and rejecting exception — the silence is what made this class of bug untraceable. Also fixes the `_resolve_current_participant_state` test to assert the raise that ARCH-15-002 now requires instead of the old default-substitution. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces a placeholder issue number in the read-path regression test's fixture docstring with #2283, which is now filed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sei-ahouseholder
left a comment
There was a problem hiding this comment.
🔧 pr-execute — PR #2278
19 triage findings: 18 fixed, 1 filed as a separate issue. 0 skipped.
Fixed in d85e426d (16 findings)
| Finding | Resolution |
|---|---|
phase5-announce-path-wire-shape-raise-0 (FAIL) |
Ingress sites project via _project_to_core_participant() before reading; the raising reader is reserved for persisted rows. |
phase8-valueerror-swallowed-in-inbox-0 (FAIL) |
_store_nested_inbox_object splits VultronValidationError (ERROR, "cannot be projected") from crud.create's duplicate-key ValueError (DEBUG, "already exists"). |
phase10-no-test-for-wire-incoming-regress-check-0 (FAIL) |
test/core/use_cases/received/case/test_helpers.py covers _would_regress_participant with a wire-shaped incoming participant. |
phase9-domain-validation-note-contradicted-0 (FAIL) |
notes/domain-validation.md gained the one-canonical-reader-per-dimension section, the ingress-projection exception, and the _wire_spelling.py location deviation. |
phase5-normalize-top-level-only-0 |
_normalize_to_core now normalises direct children. |
phase8-wire-spelled-guard-one-level-deep-0 |
Guard extracted to models/_wire_spelling.py, computed per exact class; docstrings corrected. |
phase10-no-ratchet-for-normalize-set-0 |
test/architecture/test_normalize_wire_to_core_ratchet.py pins _NORMALIZE_WIRE_TO_CORE. |
phase8-unconverted-degraded-fallbacks-0 |
Remaining RM.START / default-VFD substitutions converted, including the fallback inside the edited function. |
phase8-participant-status-summary-blank-0 |
schema.py::_dimension_state() reads either shape off a raw row dict. |
phase7-any-type-violates-cs-11-001-0 |
Any annotations narrowed or justified; variadic return type replaced. |
phase5-arch-15-004-misattributed-0 |
Citations corrected to ARCH-15-001 / ARCH-15-002. |
phase7-case-participant-over-500-lines-0 |
case_participant.py is now 469 lines. |
phase6-no-adr-for-persistence-boundary-0 |
ADR-0062 added, with docs/adr/index.md and mkdocs.yml nav entries. |
phase9-datalayer-design-note-stale-0 |
notes/datalayer-design.md documents the write-path normalisation and both ratchets. |
phase10-test-gaps-in-new-tests-0 |
Gaps in test/core/models/test_participant_status_shape.py closed. |
phase10-wire-spelled-keys-assumption-unpinned-0 |
test/core/models/test_wire_spelling.py pins the _WIRE_SPELLED_KEYS derivation. |
Fixed in b4406b2b — read-ladder containment, and the fcv-reject regression
phase8-vultronvalidationerror-escapes-read-ladder-0 turned out to be the same
defect as the branch-owned Demo Integration failure.
VultronValidationError's MRO is → VultronError → Exception — not a
ValueError subclass — so _from_row's except ValidationError never contained
the new shape guard. It is now caught, and because such a row is a wire-spelled
copy of a core type, the wire fallback is projected back with to_core() instead
of being handed to the caller. Returning it unprojected is what made
resolve_case raise Expected VulnerabilityCase, got as_VulnerabilityCase — the
422 on the finder at Phase 3 (add-note-to-case) of the fcv-reject demo.
The plain-ValidationError branch deliberately keeps its un-projected behaviour:
those rows are what the KNOWN_WIRE_ESCAPES ratchet (DL-05-004) measures, and
projecting them dehydrates inline nested objects that callers still expect inline
— that was caught by test_datalayer_serialization.py on a first, wider attempt.
Both fallbacks now log the row id, type, and rejecting exception.
Regression tests in test/adapters/driven/test_sqlite_core_roundtrip.py were
confirmed to fail without the fix, returning as_VulnerabilityCase — the
exact CI signature — and to pass with it. A third test pins the premise (the
fixture row really does fail core validation), so the pair cannot pass for the
wrong reason.
Filed as a separate issue (1 finding)
phase8-normalize-bypassed-by-crud-write-path-0 → #2283 (added to Project #24).
SqliteDataLayer.create() stores a StorableRecord's data_ verbatim
(crud.py:50-53), bypassing Record.from_obj and therefore the write-path
normalisation added here; vultron/core/behaviors/helpers.py's UpdateObject /
CreateObject inherit the bypass. save() / save_many() are unaffected.
Closing it needs a decision on whether write-path normalisation may reject a
write, so it is not folded in.
Tests
- Unit —
uv run pytest: exit 0, 0 failures / 0 errors across 12,591
test + subtest results (372 skipped, 2 known xfails: #1991, #1992). - Integration —
uv run pytest -m integration --timeout=90: exit 0, 1104
results, 0 failures / 0 errors. The explicit--timeout=90is required —
pyproject.tomlsets a 5s per-test default the multi-actor scenarios exceed.
Demo Integration — branch ownership
Docker is unavailable in the dev container, so this is per-job CI comparison, not
local reproduction.
- Branch-owned:
fcv-reject—successon main run31655077093,failure
on branch run31656886221. Fixed inb4406b2b; the next run is the
confirmation. - Pre-existing:
fcvcvandfvcv-handofffail on main run31655077093
too. Main additionally failsfvv,fcv,fccv-extension,
fvcv-extension(full-suite-only, not run on PRs).fvpasses on both. - Pre-existing: all 9
Invariant Harnessjobs are red on main independently
of this PR — they consume the demo jobs' artifacts. Partly tracked by #2273.
Other
- PR body rewritten — corrected the ARCH-15-004 misattribution and the
"projection failure raisesValueError" claim (it raises
VultronValidationError), documented the changes that were previously
unlisted (ingress projection,_wire_spelling.py,
participant_status_vfd_state, direct-child normalisation, the read-path
containment and projection,_dimension_state, ADR-0062), refreshed the test
counts to measured numbers, and added the Demo Integration exception
disclosure. - Merge state:
MERGEABLEagainstmain; no sync was needed. - Review threads: none on this PR — Phase 7 was a no-op.
Artifact: .claude/pr-2278-execute.json
sei-ahouseholder
left a comment
There was a problem hiding this comment.
✅ pr-verify — PR #2278
Overall verdict: GAPS-FOUND
Blocked by: UNVERIFIED-CI-FAILING — six red CI jobs, all proven pre-existing on main.
Merge state: MERGEABLE (UNSTABLE) — base main, not a draft, no conflict markers in the tree.
UNSTABLE reflects the failing non-required checks below, not a conflict. git grep for <<<<<<< / >>>>>>> is clean.
Phase 3 — CI
All checks have completed at e141af8d. Lint (black, flake8, mypy, pyright), Tests (pytest), Build, docs-build-check, CodeQL, and Analyze all pass.
Six jobs are red:
| Job | This branch | main (run 31655077093) |
Owned by this PR? |
|---|---|---|---|
fcv-reject Demo Integration |
pass | pass | was regressed, now fixed |
fv Demo Integration |
pass | pass | no |
fcvcv Demo Integration |
fail | fail | no |
fvcv-handoff Demo Integration |
fail | fail | no |
fv Invariant Harness |
fail | fail | no |
fcv-reject Invariant Harness |
fail | fail | no |
fcvcv Invariant Harness |
fail | fail | no |
fvcv-handoff Invariant Harness |
fail | fail | no |
Zero branch-owned CI failures remain. fcv-reject Demo Integration — the one job this branch regressed — is green again on run 31659688359, confirming the read-path projection fix. All 9 Invariant Harness jobs are red on main (including fv, whose demo job passes there), and main additionally fails fvv, fcv, fccv-extension, fvcv-extension and cancels fccv-handoff. Tracked by #2136, #2230, #2231, #2273; required status checks are not yet enabled on main (#2251).
The verdict is GAPS-FOUND because this skill's gate is categorical — CI must be green, and it is not. Nothing here is attributable to this PR. Merging past a known-red baseline is a call for a human, which is exactly why verify does not upgrade its own verdict.
Phase 4 — FAIL findings (4/4 CONFIRMED at HEAD)
| Finding | Verdict | Evidence at e141af8d |
|---|---|---|
phase5-announce-path-wire-shape-raise-0 |
CONFIRMED | _project_to_core_participant present and used in received/case/_helpers.py |
phase8-valueerror-swallowed-in-inbox-0 |
CONFIRMED | _inbox.py:198 except VultronValidationError split from :211/:221 except ValueError |
phase10-no-test-for-wire-incoming-regress-check-0 |
CONFIRMED | test/core/use_cases/received/case/test_helpers.py — TestParticipantRmStateWireShape::test_raises_on_wire_shaped_participant and TestStoreEmbeddedParticipantsWireShape::test_wire_shaped_participant_is_stored_in_the_core_shape |
phase9-domain-validation-note-contradicted-0 |
CONFIRMED | notes/domain-validation.md carries both the canonical-reader section and the "Where a raise is wrong" ingress exception |
Phase 5 — IMPROVE findings (14/14 CONFIRMED, 1 NOTED)
Spot-checked at HEAD, not just in the commit diff:
phase5-normalize-top-level-only-0—db_record.py:287documents and implements direct-child projection.phase8-wire-spelled-guard-one-level-deep-0—_wire_spelling.py:41-42caches per exact class; docstrings match behaviour.phase10-no-ratchet-for-normalize-set-0—test/architecture/test_normalize_wire_to_core_ratchet.pypresent.phase8-vultronvalidationerror-escapes-read-ladder-0—datalayer.pyhas the dedicatedexcept VultronValidationError as excbranch withto_core()projection; regression tests confirmed red without it.phase8-unconverted-degraded-fallbacks-0— all three cited sub-sites converted: (a)common.py:221-224now calls both canonical readers; (b)use_cases/_helpers.py:456callsparticipant_status_rm_state; (c) zerogetattr(status, "rm", ...)survivors undercore/behaviors/.phase8-participant-status-summary-blank-0—schema.py::_dimension_state()present and used.phase7-any-type-violates-cs-11-001-0—participant_status_rm_state(status: object),participant_status_vfd_state(status: object), andread_rm_statesnow carries fixed-arity@overloads returningtuple[RM]/tuple[RM, RM], restoring static arity checking at both unpack sites.phase5-arch-15-004-misattributed-0— zeroARCH-15-004references remain inbehaviors/helpers.py; citations are ARCH-15-001/002.phase7-case-participant-over-500-lines-0— 469 lines.phase6-no-adr-for-persistence-boundary-0— ADR-0062 present, indexed indocs/adr/index.mdandmkdocs.yml.phase9-datalayer-design-note-stale-0—notes/datalayer-design.mddocuments the write-path normalisation.phase10-test-gaps-in-new-tests-0—test_participant_status_shape.pyhas 11 tests.phase10-wire-spelled-keys-assumption-unpinned-0—test/core/models/test_wire_spelling.pypresent.phase3-verification-omits-demo-ci-exception-0— PR body now carries the "Demo Integration status — exception disclosed" subsection with per-job main-vs-branch comparison.phase8-normalize-bypassed-by-crud-write-path-0— NOTED: #2283 exists and is OPEN.
Observation — one unconverted sibling outside the finding set
Not part of any triage finding, so it does not change the verdict, but it is the same idiom this PR removes:
vultron/core/behaviors/case/nodes/participant/common.py:392
latest_rm = statuses[-1].rm.state if statuses else RM.STARTensure_reporter_participant reads a participant straight from the DataLayer and dereferences .rm.state without the canonical reader. On a wire-shaped or unprojectable row .rm is None, so this raises AttributeError rather than the VultronValidationError the rest of the module now raises. The read-path projection added here makes that row shape much rarer, but it is not the guard the PR standardises on. Worth a one-line follow-up.
Verdict rationale
| Gate | Result |
|---|---|
| Merge conflict | clear — MERGEABLE, no markers |
| FAIL findings unresolved | none — 4/4 CONFIRMED |
| Execute completeness | 19 triage findings → 19 execute results |
| Merge state unknown | no |
| CI green | no — 6 red jobs, 0 branch-owned |
Artifacts retained at .claude/pr-2278-{triage,execute}.json.
Summary
ParticipantStatushad two structurally incompatible shapes — core nestsrm: RmDimension/vfd: VfdDimension(SDO-03-002, ADR-0036), wire carries flatrm_state/vfd_state— and both could land in the same DataLayer row, so whichever class read the row back decided what the data meant. This PR establishes one canonical persisted shape: wire objects are projected to core at both normalization boundaries (inbound ingress and the persistence write path, ADR-0062), and every remaining shape mismatch raises instead of degrading toNoneor a fabricatedRM.START.Changes
Canonical readers
vultron/core/models/participant_status.py: addsparticipant_status_rm_state()andparticipant_status_vfd_state(), the single canonical readers for each dimension. Each raisesVultronValidationErrorwhen its dimension is absent (i.e. a wire-shaped status) or present but carries no valid state, with a message naming both shapes and pointing at the wire→core boundary. These live beside the model rather than inmodels/_helpers.pybecause that module is imported bymodels/base.py, and importingvultron.core.states.rmfrom it triggers a circular import throughstates/__init__.py.vultron/core/behaviors/helpers.py: addsread_rm_states(), the shared BT-node guard. Converts aVultronValidationErrorfrom the canonical reader intofeedback_message+ a logged error and signalsStatus.FAILUREto the caller, per ARCH-15-001 (a node MUST return FAILURE rather than a degraded SUCCESS) and ARCH-15-002 (the helper raises immediately).vultron/core/behaviors/status/nodes/append.py:ValidateRMTransitionNodeandCheckParticipantRMNotClosedNoderead RM state throughread_rm_states()instead ofgetattr(status, "rm", None). Extracting the guard intohelpers.pywas also required to keep this module within the 500-line BTND-07-004 ceiling — it now sits exactly at 500, which is why BTND-07-004's 500-line ceiling is forcing unrelated decomposition churn — append.py and effects.py both at the limit #2269 was filed.vultron/core/behaviors/case/nodes/participant/common.py: replacesRM.STARTsubstitution with the raising reader (Bug: three sites silently substitute RM.START when a ParticipantStatus exposes no rm dimension #2264)._participant_rm_statestill returnsNonefor a genuinely empty status list — absence is legitimate; unreadability is not.Shape guards
vultron/core/models/_wire_spelling.py(new, 116 lines):reject_wire_spelled_keys(), the mirror image of the readers above. A core type validated against a wire-spelled (camelCase) payload drops every snake-only key in silence, because Pydantic v2 ignores unknown keys. The wire-spelled key set is computed per exact class, so aCaseParticipantrole subclass that adds a field is covered with no registration step. It lives here, not inmodels/_helpers.py, because shape guards accumulate state-enum references and_helpers.pycannot importvultron.core.states— a deliberate deviation from the canonical-helper-location rule, documented innotes/domain-validation.md.vultron/core/models/case_participant.py: amodel_validator(mode="before")calls that guard. Core has noalias_generator(ARCH-12-003 forbids it in core-branch types), so a camelCaseparticipantStatuseswas an unknown key, silently ignored, and_init_participant_status_if_emptythen re-seeded a singleRM.STARTstatus — a full RM ladder silently became one entry. Fields with a declaredvalidation_alias(inReplyTo,id,type) are exempt.Normalization boundaries (ADR-0062)
vultron/adapters/driven/db_record.py:Record.from_objnow calls_normalize_to_core()before serializing. Wire vocabularytype_values are bare ("CaseParticipant", not"as_CaseParticipant"), so the pre-existingtype_.startswith("as_")guard never fired for them and 15 wire classes could be written into a core-typed row. For the two types whose shapes are structurally incompatible —CaseParticipantandParticipantStatus— the object is projected viato_core()(_project_shadowing_wire_obj), and direct children are normalized too, so a wire-shaped status nested one level down inside a core parent is projected rather than persisted as-is. A projection failure raisesVultronValidationErrorrather than storing an unreadable row._NORMALIZE_WIRE_TO_COREis a grow-only ratchet, the write-side analogue ofKNOWN_WIRE_ESCAPES(DL-05-004), pinned bytest/architecture/test_normalize_wire_to_core_ratchet.py.vultron/core/use_cases/received/case/_helpers.py: adds_project_to_core_participant()and_would_regress_participant(). At a wire→core ingress boundary a wire-shaped status is legitimate inbound data, not a corrupt row, so these sites project before reading rather than letting the canonical reader raise. Making the reader strict without projecting at ingress first aborted the whole received-case behavior tree on every inboundAnnounce— that is how the first fix for ParticipantStatus is written to one DataLayer row in two incompatible model shapes (wire flat vs core nested) #2232 regressed, and the asymmetry is now spelled out innotes/domain-validation.md.vultron/adapters/driving/fastapi/routers/actors/_inbox.py:_store_nested_inbox_objectdistinguishes the newVultronValidationErrorfromcrud.create's duplicate-keyValueError. Without the split, an unprojectable object was logged at DEBUG as "already exists" and dropped silently; it is now an ERROR naming the projection failure.CaseLedgerEntrystays exempt per SYNC-13-002.DataLayer read path
vultron/adapters/driven/datalayer_sqlite/datalayer.py:_from_rowcaught onlyValidationErroraroundcore_cls.model_validate.VultronValidationError's MRO is→ VultronError → Exception— it is not aValueErrorsubclass — so the new shape guard escaped the fallback ladder entirely instead of being contained like every other shape mismatch (DL-05-002). It is now caught, and because such a row is a wire-spelled copy of a core type, the wire fallback is projected back withto_core()before being returned. Handing the wire object back unprojected is what maderesolve_caseraiseExpected VulnerabilityCase, got as_VulnerabilityCase. The plain-ValidationErrorbranch deliberately keeps its existing un-projected behaviour: those rows (e.g.as_EmbargoEvent, which lackscontext) are what theKNOWN_WIRE_ESCAPESratchet measures, and projecting them would dehydrate inline nested objects that callers still expect inline. Both fallbacks now log the row id, type, and rejecting exception — the silence is what made this class of bug untraceable.vultron/adapters/driven/datalayer_sqlite/schema.py:_dimension_state()lets the save-timeparticipant_status_summarydiagnostic read either shape off a raw row dict. Without it the diagnostic went permanently blank for participants once the canonical readers started raising.Docs and specs
docs/adr/0062-normalise-wire-to-core-at-both-ingress-and-persistence.md(new): records why normalization is needed at two boundaries rather than one, and why the ingress boundary projects where the persistence boundary rejects.notes/domain-validation.md: new sections on the one-canonical-reader-per-dimension rule, the ingress exception, and the_wire_spelling.pylocation deviation.notes/datalayer-design.md: documents the write-path normalization and the two ratchets.Verification
uv run pytest: 0 failures / 0 errors across 12,591 test + subtest results (372 skipped, 2 known xfails: Remove alias_generator=to_camel from core-layer classes (ARCH-12-004) #1991, Remove core-layer actor classes from wire VOCABULARY registry (ARCH-12-003) #1992). 15 new tests.uv run pytest -m integration --timeout=90: 1104 results, 0 failures / 0 errors. The explicit--timeout=90is required:pyproject.tomlsets a 5s per-test default that the multi-actor integration scenarios exceed.participantStatusesreduced a 2-entry ladder to['START']; a wireCaseParticipantpersisted with flatrm_stateand norm; a wireParticipantStatuspersistedrm_state=VALID, rm=None; andresolve_participant_state_from_dlreturnedRM.STARTwhere the real state wasRM.VALID. All four now raise.test/adapters/driven/test_sqlite_core_roundtrip.pywere confirmed to fail without thedatalayer.pyfix, returningas_VulnerabilityCase— the exact signature seen in CI — and to pass with it. A third test pins the premise (the fixture row really does fail core validation), so the pair cannot pass for the wrong reason.test_resolve_participant_state_defaults_when_invalid_rm_typeand..._invalid_vfd_type, both of which expectedRM.START/ a default VFD state for a status whose dimension was unreadable — exactly the Bug: three sites silently substitute RM.START when a ParticipantStatus exposes no rm dimension #2264 defect.TestPublicDisclosureBranchNodefixtures builtas_CaseStatus()withoutcontext, which coreCaseStatusrequires; the fixture data was completed so the nested status is projectable.Demo Integration status — exception disclosed
Docker is unavailable in the dev container, so the docker-compose demo cannot be run locally; the assessment below is from per-job comparison of CI runs, not local reproduction.
fcv-reject Demo Integrationpassed on main (run 31655077093), failed on this branch before the fix (run 31656886221) with{"status":422,"error":"ValidationError","message":"Expected VulnerabilityCase, got as_VulnerabilityCase."}on the finder at Phase 3 (add-note-to-case), and passes again on run 31659688359 with the read-path projection in place. That was theVultronValidationError-escapes-the-read-ladder defect described above.fcvcvandfvcv-handoff Demo Integrationfail on main run 31655077093 as well as here. Main additionally failsfvv,fcv,fccv-extension, andfvcv-extension(full-suite-only scenarios that do not run on PRs), and cancelsfccv-handoff.fv Demo Integrationpasses on both.Invariant Harnessjob — all 9 on main run 31655077093, includingfv Invariant Harnesseven thoughfv Demo Integrationpasses there — is red independently of this PR. The 4 that run on PRs (fv,fcv-reject,fcvcv,fvcv-handoff) fail identically here. Partly tracked by fv demo emits no validate_report eventType, so invariant 5 fails on a clean main #2273.No branch-owned CI failure remains.
Follow-ups filed
CORE_VOCABULARYkey; they differ only by key spelling today, so they are not yet normalized.append.pyis now exactly at the 500-line BTND-07-004 ceiling.validate_reportfailure, plus two invariant-harness hazards found while proving it pre-existing (devlog accumulation across runs, andDEVLOGS_DIRdefaulting to a hardcoded/app/devlogs).SqliteDataLayer.create()stores aStorableRecord'sdata_verbatim, bypassingRecord.from_objand therefore the write-path normalization added here.save()/save_many()are unaffected. Closing that hole needs a decision about whether write-path normalization may reject a write, which is why it is not folded in.alias_generatorfrom a core type changes the persisted key shape, so those need a migration story rather than a straight deletion.Note
There is no write-side DataLayer shape spec — ADR-0034/DL-05 constrains only what
read()returns. That asymmetry is what allowed this bug, and is recorded as aspec-gaplearning inplan/incoming/learnings/.