Skip to content

fix: one canonical persisted shape for ParticipantStatus - #2278

Open
sei-ahouseholder wants to merge 6 commits into
mainfrom
bug/2232-participant-status-shape-duality
Open

fix: one canonical persisted shape for ParticipantStatus#2278
sei-ahouseholder wants to merge 6 commits into
mainfrom
bug/2232-participant-status-shape-duality

Conversation

@sei-ahouseholder

@sei-ahouseholder sei-ahouseholder commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

ParticipantStatus had two structurally incompatible shapes — core nests rm: RmDimension / vfd: VfdDimension (SDO-03-002, ADR-0036), wire carries flat rm_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 to None or a fabricated RM.START.

Changes

Canonical readers

  • vultron/core/models/participant_status.py: adds participant_status_rm_state() and participant_status_vfd_state(), the single canonical readers for each dimension. Each raises VultronValidationError when 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 in models/_helpers.py because that module is imported by models/base.py, and importing vultron.core.states.rm from it triggers a circular import through states/__init__.py.

  • vultron/core/behaviors/helpers.py: adds read_rm_states(), the shared BT-node guard. Converts a VultronValidationError from the canonical reader into feedback_message + a logged error and signals Status.FAILURE to 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: ValidateRMTransitionNode and CheckParticipantRMNotClosedNode read RM state through read_rm_states() instead of getattr(status, "rm", None). Extracting the guard into helpers.py was 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: replaces RM.START substitution with the raising reader (Bug: three sites silently substitute RM.START when a ParticipantStatus exposes no rm dimension #2264). _participant_rm_state still returns None for 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 a CaseParticipant role subclass that adds a field is covered with no registration step. It lives here, not in models/_helpers.py, because shape guards accumulate state-enum references and _helpers.py cannot import vultron.core.states — a deliberate deviation from the canonical-helper-location rule, documented in notes/domain-validation.md.

  • vultron/core/models/case_participant.py: a model_validator(mode="before") calls that guard. Core has no alias_generator (ARCH-12-003 forbids it in core-branch types), so a camelCase participantStatuses was an unknown key, silently ignored, and _init_participant_status_if_empty then re-seeded a single RM.START status — a full RM ladder silently became one entry. Fields with a declared validation_alias (inReplyTo, id, type) are exempt.

Normalization boundaries (ADR-0062)

  • vultron/adapters/driven/db_record.py: Record.from_obj now calls _normalize_to_core() before serializing. Wire vocabulary type_ values are bare ("CaseParticipant", not "as_CaseParticipant"), so the pre-existing type_.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 — CaseParticipant and ParticipantStatus — the object is projected via to_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 raises VultronValidationError rather than storing an unreadable row. _NORMALIZE_WIRE_TO_CORE is a grow-only ratchet, the write-side analogue of KNOWN_WIRE_ESCAPES (DL-05-004), pinned by test/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 inbound Announce — 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 in notes/domain-validation.md.

  • vultron/adapters/driving/fastapi/routers/actors/_inbox.py: _store_nested_inbox_object distinguishes the new VultronValidationError from crud.create's duplicate-key ValueError. 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. CaseLedgerEntry stays exempt per SYNC-13-002.

DataLayer read path

  • vultron/adapters/driven/datalayer_sqlite/datalayer.py: _from_row caught only ValidationError around core_cls.model_validate. VultronValidationError's MRO is → VultronError → Exception — it is not a ValueError subclass — 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 with to_core() before being returned. Handing the wire object back unprojected is what made resolve_case raise Expected VulnerabilityCase, got as_VulnerabilityCase. The plain-ValidationError branch deliberately 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. 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-time participant_status_summary diagnostic 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.py location deviation.
  • notes/datalayer-design.md: documents the write-path normalization and the two ratchets.

Verification

  • Unit suite greenuv 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.
  • Integration suite greenuv run pytest -m integration --timeout=90: 1104 results, 0 failures / 0 errors. The explicit --timeout=90 is required: pyproject.toml sets a 5s per-test default that the multi-actor integration scenarios exceed.
  • Black, flake8, mypy, pyright clean.
  • Reproduced each failure mode before fixing: an explicit camelCase participantStatuses reduced a 2-entry ladder to ['START']; a wire CaseParticipant persisted with flat rm_state and no rm; a wire ParticipantStatus persisted rm_state=VALID, rm=None; and resolve_participant_state_from_dl returned RM.START where the real state was RM.VALID. All four now raise.
  • The read-path regression tests in test/adapters/driven/test_sqlite_core_roundtrip.py were confirmed to fail without the datalayer.py fix, returning as_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.
  • Two pre-existing tests asserted the bug and were rewritten to expect the raise: test_resolve_participant_state_defaults_when_invalid_rm_type and ..._invalid_vfd_type, both of which expected RM.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.
  • Two TestPublicDisclosureBranchNode fixtures built as_CaseStatus() without context, which core CaseStatus requires; 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.

  • Branch-owned and fixed — confirmed green. fcv-reject Demo Integration passed 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 the VultronValidationError-escapes-the-read-ladder defect described above.
  • Pre-existing, not owned by this PR: fcvcv and fvcv-handoff Demo Integration fail on main run 31655077093 as well as here. Main additionally fails fvv, fcv, fccv-extension, and fvcv-extension (full-suite-only scenarios that do not run on PRs), and cancels fccv-handoff. fv Demo Integration passes on both.
  • Pre-existing: every Invariant Harness job — all 9 on main run 31655077093, including fv Invariant Harness even though fv Demo Integration passes 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

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 a spec-gap learning in plan/incoming/learnings/.

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 sei-ahouseholder left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ⚠️ IMPROVE _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 ⚠️ IMPROVE 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 ⚠️ IMPROVE _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 ⚠️ IMPROVE 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 ⚠️ IMPROVE 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 ⚠️ IMPROVE 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 ⚠️ IMPROVE 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 ⚠️ IMPROVE 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 ⚠️ IMPROVE 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 ⚠️ IMPROVE 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 ⚠️ IMPROVE Alternatives weighed and rejected (MS-11-002) + persistence shape change, but no ADR fix-now
phase9-datalayer-design-note-stale-0 notes-currency ⚠️ IMPROVE 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 ⚠️ IMPROVE Verification section omits the new Demo Integration failures this branch introduces fix-now
phase10-test-gaps-in-new-tests-0 test-coverage ⚠️ IMPROVE 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 ⚠️ IMPROVE _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).

ahouseholder and others added 3 commits August 13, 2026 01:09
- 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>
`_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 sei-ahouseholder left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 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 → Exceptionnot 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

  • Unituv run pytest: exit 0, 0 failures / 0 errors across 12,591
    test + subtest results (372 skipped, 2 known xfails: #1991, #1992).
  • Integrationuv run pytest -m integration --timeout=90: exit 0, 1104
    results, 0 failures / 0 errors. The explicit --timeout=90 is required —
    pyproject.toml sets 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-rejectsuccess on main run 31655077093, failure
    on branch run 31656886221. Fixed in b4406b2b; the next run is the
    confirmation.
  • Pre-existing: fcvcv and fvcv-handoff fail on main run 31655077093
    too. Main additionally fails fvv, fcv, fccv-extension,
    fvcv-extension (full-suite-only, not run on PRs). fv passes on both.
  • Pre-existing: all 9 Invariant Harness jobs 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 raises ValueError" 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: MERGEABLE against main; no sync was needed.
  • Review threads: none on this PR — Phase 7 was a no-op.

Artifact: .claude/pr-2278-execute.json

@sei-ahouseholder sei-ahouseholder left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.pyTestParticipantRmStateWireShape::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-0db_record.py:287 documents and implements direct-child projection.
  • phase8-wire-spelled-guard-one-level-deep-0_wire_spelling.py:41-42 caches per exact class; docstrings match behaviour.
  • phase10-no-ratchet-for-normalize-set-0test/architecture/test_normalize_wire_to_core_ratchet.py present.
  • phase8-vultronvalidationerror-escapes-read-ladder-0datalayer.py has the dedicated except VultronValidationError as exc branch with to_core() projection; regression tests confirmed red without it.
  • phase8-unconverted-degraded-fallbacks-0 — all three cited sub-sites converted: (a) common.py:221-224 now calls both canonical readers; (b) use_cases/_helpers.py:456 calls participant_status_rm_state; (c) zero getattr(status, "rm", ...) survivors under core/behaviors/.
  • phase8-participant-status-summary-blank-0schema.py::_dimension_state() present and used.
  • phase7-any-type-violates-cs-11-001-0participant_status_rm_state(status: object), participant_status_vfd_state(status: object), and read_rm_states now carries fixed-arity @overloads returning tuple[RM] / tuple[RM, RM], restoring static arity checking at both unpack sites.
  • phase5-arch-15-004-misattributed-0 — zero ARCH-15-004 references remain in behaviors/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 in docs/adr/index.md and mkdocs.yml.
  • phase9-datalayer-design-note-stale-0notes/datalayer-design.md documents the write-path normalisation.
  • phase10-test-gaps-in-new-tests-0test_participant_status_shape.py has 11 tests.
  • phase10-wire-spelled-keys-assumption-unpinned-0test/core/models/test_wire_spelling.py present.
  • 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-0NOTED: #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.START

ensure_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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 301+ diff lines or 7+ ACs

Projects

None yet

2 participants