fix(ledger): wire the CLP-14/15 timestamp guard into the commit boundary - #3159
fix(ledger): wire the CLP-14/15 timestamp guard into the commit boundary#3159sei-ahouseholder wants to merge 2 commits into
Conversation
`_validate_entry_timestamps` implemented CLP-14-002/003/006/007/008 but ran only under `if case_published is not None:`, and `CreateLogEntryNode.update()` — its sole production caller — never passed that argument. The guard was dead code from the commit that introduced it. `check_clp14_timestamp_invariants` was likewise never imported by `universal_harness.py`, so no scenario ran it either, while ADR-0079 § "Validation" asserted both were running. Splits the two timestamps an entry carries to the layers that own them: commit-stamp invariants to the CI harness, claimed-timestamp invariants to the commit boundary, with claimed-time monotonicity scoped per snapshot actor so it does not reintroduce the wall-clock ordering ADR-0079 rejected. Also carries the sender's `published` across the wire→core boundary, without which CLP-14-007/008 compare the receiver's clock against itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PD-03-007: `docs/topics/case_ledger_sync.md` described CLP-14-003's "never move backwards" rule without saying which of an entry's two timestamps it applies to, and listed the four CLP-15 participant obligations without saying which the Case Actor can check. Adds the commit-stamp vs. claimed-time table, notes that claimed-time monotonicity is per participant, and cites CLP-14-009 for the tunable thresholds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sei-ahouseholder
left a comment
There was a problem hiding this comment.
PR Triage: #3159 — fix(ledger): wire the CLP-14/15 timestamp guard into the commit boundary
Linked issues: #2824, #3149, #3158
Changed files: 27 files (+1182 / −254) — core/behaviors/sync, core/behaviors/case, core/use_cases/received, core/models, wire/as2/extractor, config, adapters/driving/fastapi, test/ci/invariants
CI status: ✅ passing (22 checks green on 0d8b2a7, incl. 4 demo-integration + 4 invariant-harness scenarios)
Merge state: ✅ mergeable (CLEAN)
Base branch: main
Needs integration tests: yes
Findings
| # | Phase | Severity | Description | Outcome |
|---|---|---|---|---|
phase8-clp15003-reorder-silent-loss-0 |
code-review | ❌ FAIL | CLP-15-003 hard-rejects a legitimate assertion when two distinct activities from one participant arrive out of emission order — and the activity is silently, permanently lost (no rejected entry, no retry, effect nodes skipped) |
fix-now |
phase8-clp15003-cross-clock-zero-tolerance-1 |
code-review | ❌ FAIL | CLP-15-003 compares with zero tolerance across two clocks in one actor's stream: receive_close_case_tree.py:159 and embargo.py:487 stamp the CaseActor's clock under a participant's actor URI. Any forward participant skew fails the owner-Leave close commit |
fix-now |
phase5-clp14006-tolerance-unauthorised-2 |
spec-conformance | ❌ FAIL | A 5-min tolerance weakens MUST-level CLP-14-006, which no spec entry authorises (CLP-14-009 is MAY and covers only CLP-14-007/008); ADR-0079 amended but specs/case-ledger-processing.yaml untouched |
fix-now |
phase5-clp14-15-spec-text-unamended-3 |
spec-conformance | 🎫 NEW-ISSUE | Spec-level premise inversion tracked only in a plan/incoming/learnings/ file, which the completeness doctrine says is not tracking |
inversion-halt |
phase8-clp14-007-008-still-bypassable-4 |
code-review | The #3149 fix is partial: as_Base.published has default_factory=now_utc, so a sender that omits published still gets the receiver's clock stored as its "claim" — CLP-14-007/008 stay vacuous and bypassable |
fix-now | |
phase10-ledger-snapshots-published-untested-5 |
test-coverage | test/core/behaviors/case/test_ledger_snapshots.py (CLP-07-011's declared verification) has zero published assertions and was not touched; no boundary test for CLP-14-008; 2 of 3 new env vars unverified |
fix-now | |
phase9-notes-configuration-stale-6 |
notes-currency | notes/configuration.md env-var table and AppConfig sketch omit the new ledger section; the new cross-link from case-ledger-authority.md is one-way |
fix-now | |
phase8-double-full-ledger-scan-7 |
code-review | CreateLogEntryNode.update() now makes two eager full list_objects("CaseLedgerEntry") scans per commit (three with ReconstructChainTail), unfiltered by case, paid even for disposition="rejected" |
fix-now | |
phase8-defer-case-missing-include-activity-8 |
code-review | 🎫 NEW-ISSUE | DEFER_CASE omits include_activity=True, so a received Ignore(VulnerabilityCase) never ledgers and loses the RM→DEFERRED transition. Pre-existing, not a regression here |
fix-now-file |
phase3-verification-missing-test-counts-9 |
pr-body-format | Verification section reports "exit 0" instead of the real total/new test counts required by pr-body-guide.md |
fix-now | |
phase8-clp14006-case-published-cross-clock-10 |
code-review | _build_case_object keeps a wire-sourced originator's clock on case.published, so CLP-14-006 can mix clocks too (candidate exposure: ownership-transfer transferee) — PLAUSIBLE, unconfirmed |
fix-now | |
phase8-parse-published-docstring-inaccurate-11 |
code-review | parse_published promises a UTC datetime but _as_utc leaves an aware non-UTC offset unchanged. Harmless for comparisons; wrong contract on a newly shared helper |
fix-now |
Total: 3 FAIL · 7 IMPROVE · 2 NEW-ISSUE
What's solid
Worth stating plainly, because the diff is large and the findings above are concentrated in one area:
- The core diagnosis is right and well evidenced.
_validate_entry_timestampsreally was dead code gated behind one optional argument,check_clp14_timestamp_invariantsreally was never imported byuniversal_harness.py, and ADR-0079 really did assert both were running. - Removing all 7
xfail(strict=True)markers while retargeting each stub at the layer that owns its requirement is the correct call, and every@pytest.mark.specmarker survived. test_chain_timestamp_guard.pyexercising the guard throughCreateLogEntryNodeis the right shape, and theconfig_overridetest asserting the override actually bound is exactly the anti-vacuity discipline this PR is about.- An exhaustive AST audit of every
payload_snapshotdict literal invultron/carrying bothtype/type_andactorreturns 8 producers, all withpublished. The PR's claim of seven missed sites is accurate. - CI is fully green on the head SHA, and
test/metadata+test/architecturepass locally (AGENTS.md is back to exactly 400 lines, clearing #3158).
The through-line in the three FAILs
All three are the same root shape: the PR correctly identifies that comparing claimed timestamps across actors is the wall-clock ordering ADR-0079 rejected, then applies that insight only to the cross-actor case. Within one actor's stream the same hazard is still live — arrival order is used as a proxy for causal order (which CLP-15-005 forbids and CLP-15-003's own "causally related" qualifier excludes), the comparison has no skew tolerance, and two of the PR's own new published stamps mix the CaseActor's clock into a participant's stream. The failure mode is worse than a false rejection: the activity is dropped with no ledger record and no retry.
Triage artifact: .claude/pr-3159-triage.json
Next step: /pr-execute (or /pr-ship) — note that finding phase5-clp14-15-spec-text-unamended-3 is an inversion-halt, so execute will pause for a human decision.
Summary
_validate_entry_timestampsimplemented CLP-14-002/003/006/007/008 but ran only underif case_published is not None:, andCreateLogEntryNode.update()— its sole production caller — never passed that argument, so the guard was dead code from the commit that introduced it.check_clp14_timestamp_invariantswas likewise never imported byuniversal_harness.py, so no scenario ran it either, while ADR-0079 § "Validation" asserted both were running. This wires both, and splits the two timestamps an entry carries to the layers that can actually own them.What #2824 got wrong
The issue asked to implement seven invariants and delete seven
xfail(strict=True)stubs. The invariants were already implemented; what was missing was the wiring. Three of the seven stubs could never have gone green as written:CaseLedgerEntry. That layer never sees its predecessor or its parent case, so it structurally cannot enforce them.check_causal_edges(DEMOMA-22-005), which is the only vantage point from which an emission-order obligation is observable.The two-timestamp split
An entry carries a commit stamp (
CaseLedgerEntry.published, written by the CaseActor) and a claimed time (payloadSnapshot.published, chosen by the asserting actor). The old code applied CLP-14-003 and CLP-14-006 to the claimed time, which is wrong in both directions: monotonicity across different actors' claimed times is exactly the wall-clock ordering ADR-0079 rejected as option C, so participant A stamping T5 and B stamping T3 would have had a well-formed assertion rejected because two clocks disagreed.Now: commit-stamp invariants go to the CI harness (true by construction, one writer one clock); claimed-time invariants go to the commit boundary, with monotonicity scoped per snapshot actor, which is what CLP-15-003 actually says.
Changes
vultron/core/behaviors/sync/nodes/canonical_entry.py:_validate_entry_timestampsno longer runs behind a single optional argument — each check gates on the context it needs. Drops the cross-actor CLP-14-003 comparison in favour of per-actor CLP-15-003; adds a clock-skew tolerance to CLP-14-006; re-cites the required-publishedcheck as CLP-07-011 (the verbatim-AS2-activity requirement) rather than CLP-14-002, which constrains the commit stamp the model already enforces.vultron/core/behaviors/sync/nodes/chain.py: resolvescase_publishedand the per-actor predecessor from the DataLayer and passes them plus the configured tolerances. A missing case is expected, not an error — the genesis entry commits alongside case creation.vultron/core/sync_helpers.py: adds_find_prev_actor_published. ReturnsNonewhen this exact assertion is already recorded, so a redelivery is not mistaken for a regression — otherwise the ordering check and the idempotency path contradict each other and out-of-order delivery (ADR-0037) becomes a hard failure.vultron/wire/as2/extractor/_builders.py: carriespublishedacross the wire→core boundary. Without it the sender's claimed time was replaced by the receiver's clock, making CLP-14-007/008 compare that clock against itself — they could never fire. Closes AS2 extractor drops the sender's published timestamp, making CLP-14-007/008 vacuous #3149.vultron/config/ledger.py(new),config/app.py,config/__init__.py:LedgerConfigunderAppConfig.ledgerexposes the skew tolerance, future tolerance and staleness window (CLP-14-009), settable asVULTRON_LEDGER__*. Nested to match theserver/database/actorconvention.vultron/core/behaviors/case/ledger_snapshots.py,receive_close_case_tree.py,use_cases/received/embargo.py,routers/demo_triggers.py: seven hand-built CaseActor snapshots never setpublished. Wiring the guard without this fails CLP-07-011 on every case creation, so it is not an optional part of the fix.vultron/core/models/_helpers.py:parse_publishedshared by the guard and the predecessor lookup (CS-22-001); replaces a private copy incanonical_entry.pyand reuses the existing_as_utcrather than adding a second one.test/ci/invariants/universal_harness.py: runscheck_clp14_timestamp_invariantsin every scenario.test/core/behaviors/sync/nodes/test_chain_timestamp_guard.py(new, 8 tests): exercises the guard throughCreateLogEntryNode. The old tests all called the private validator directly, which is why 14 of them passed against a guard that never ran in production.test/core/models/test_clp_14_15_invariants.py: all 7xfailmarkers removed; every stub retargeted at the layer that owns its requirement, all@pytest.mark.specmarkers preserved.docs/adr/0079-case-ledger-causal-ordering.md,notes/case-ledger-authority.md,AGENTS.md: the ADR's Validation section claimed checks were running that were not; the note and pitfall index now record the two-timestamp split and the three traps.AGENTS.md: recovered 5 lines to clear the size ratchet, which was already red onmain. Closes AGENTS.md size ratchet is red on main: 405 lines against a 400-line target #3158.Verification
-m ""passesmkdocs build --strictclean; markdownlint cleanFollow-ups filed
plan/incoming/learnings/20260903-2824-clp14-15-do-not-name-their-timestamp.md— CLP-14/15 never name which timestamp they constrain, and CLP-15-001/002 read as CaseActor duties that CLP-15-005 forbids🤖 Generated with Claude Code