Skip to content

fix(ledger): wire the CLP-14/15 timestamp guard into the commit boundary - #3159

Open
sei-ahouseholder wants to merge 2 commits into
mainfrom
bug/2824-clp-14-15-timestamp-invariants
Open

fix(ledger): wire the CLP-14/15 timestamp guard into the commit boundary#3159
sei-ahouseholder wants to merge 2 commits into
mainfrom
bug/2824-clp-14-15-timestamp-invariants

Conversation

@sei-ahouseholder

Copy link
Copy Markdown
Contributor

Summary

_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, so 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. 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:

  • CLP-14-003 / CLP-14-006 stubs asserted cross-entry invariants against a single bare CaseLedgerEntry. That layer never sees its predecessor or its parent case, so it structurally cannot enforce them.
  • CLP-15-001 / CLP-15-002 stubs expected the CaseActor to reject a participant's out-of-causal-order emission per assertion. CLP-15-005 explicitly forbids that ("MUST NOT attempt to reconstruct participant-internal causal order it cannot verify"), as does ADR-0079 § "Residual Uncertainty". Their real verification is 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_timestamps no 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-published check 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: resolves case_published and 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. Returns None when 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: carries published across 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: LedgerConfig under AppConfig.ledger exposes the skew tolerance, future tolerance and staleness window (CLP-14-009), settable as VULTRON_LEDGER__*. Nested to match the server/database/actor convention.
  • 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 set published. 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_published shared by the guard and the predecessor lookup (CS-22-001); replaces a private copy in canonical_entry.py and reuses the existing _as_utc rather than adding a second one.
  • test/ci/invariants/universal_harness.py: runs check_clp14_timestamp_invariants in every scenario.
  • test/core/behaviors/sync/nodes/test_chain_timestamp_guard.py (new, 8 tests): exercises the guard through CreateLogEntryNode. 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 7 xfail markers removed; every stub retargeted at the layer that owns its requirement, all @pytest.mark.spec markers 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 on main. Closes AGENTS.md size ratchet is red on main: 405 lines against a 400-line target #3158.

Verification

  • Unit suite passes (exit 0); integration suite passes (exit 0); full suite with -m "" passes
  • Black, flake8, mypy, pyright clean; mkdocs build --strict clean; markdownlint clean
  • Spec-coverage ratchet passes with zero CLP-14/CLP-15 requirements uncovered
  • Each new negative test verified non-vacuous — the redelivery carve-out was confirmed by disabling it and watching the test go red with the expected CLP-15-003 message

Follow-ups filed

🤖 Generated with Claude Code

`_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 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: #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 ⚠️ IMPROVE 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 ⚠️ IMPROVE 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 ⚠️ IMPROVE 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 ⚠️ IMPROVE 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 ⚠️ IMPROVE 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 ⚠️ IMPROVE _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 ⚠️ IMPROVE 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_timestamps really was dead code gated behind one optional argument, check_clp14_timestamp_invariants really was never imported by universal_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.spec marker survived.
  • test_chain_timestamp_guard.py exercising the guard through CreateLogEntryNode is the right shape, and the config_override test asserting the override actually bound is exactly the anti-vacuity discipline this PR is about.
  • An exhaustive AST audit of every payload_snapshot dict literal in vultron/ carrying both type/type_ and actor returns 8 producers, all with published. The PR's claim of seven missed sites is accurate.
  • CI is fully green on the head SHA, and test/metadata + test/architecture pass 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.

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