fix(feedback): seal the event time in the ledger, not the write time - #45
Conversation
record_feedback accepts an `at`, writes it to the store row, and then appended the ledger entry without it -- so the tamper-evident record an auditor reads disagreed with the store about when the review happened. A feedback backdated to January was sealed under September's wall clock. Even the default path took two separate clock readings, one for the row and one for the chain. Pass `at=entry.at`, the contract runner.record already keeps with at=run.finished_at, and that ledger.py's docstring blesses explicitly: timestamps may be supplied, because append order is proven by the entry's seq rather than by its stamp. Nothing about the chain changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XjLWb6igee2tVTG7wg93Fx
Gate review: forwarding entry.at was right, but it imported the row's local offset into a ledger every other writer keeps in UTC -- runner.record via datetime.now(UTC), the demo seeder via explicit tzinfo=UTC. Because `audit tail` renders entry["at"][:16], slicing the offset off before the reader sees it, a review recorded from a UTC-7 machine displayed seven hours BEFORE the run it reviews, with nothing on screen to explain it. That regressed the two live entry points, the CLI and the Slack button, to fix a path nothing in the product calls yet. Normalize at the append site, so the invariant holds for an explicit caller too: the alternative of defaulting the row to UTC only covers the path where no `at` is passed, and lets an explicitly local one back into the file. The tests were blind to this -- one asserted the sealed stamp equalled entry.at.isoformat(), a tautology once the value is forwarded verbatim, whatever offset it carries. They now compare instants and pin the convention: every ledger stamp ends in +00:00 even when the caller hands in a Tokyo offset, and feedback never predates its run. Suite green under TZ=UTC, America/Los_Angeles and Europe/Madrid. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XjLWb6igee2tVTG7wg93Fx
Gate verdict — PASS after one amendment roundTwo adversarial reviews, each told to refute the fix. They converged independently on the same defect, which is worth recording because it was worse than the bug being fixed. The finding
That is not cosmetic, because Written 0.1s apart. On The remedy, and why not the other oneOne review proposed fixing the row's default instead ( Verified after the change, same commands: The tests were the real failureBoth reviews flagged that the suite shipped this green. Now they compare instants rather than strings, and a third test pins the convention: every stamp ends in Verified
Ruled out by the reviews
CI green. Left open for human review. |
Found by an autonomous
bughuntiteration.Bug
record_feedbacktakes anattimestamp, writes it to the store row, and then appends the ledger entry without it. The store and the audit ledger end up describing the same event at different times.For a project whose thesis is "an auditable ROI trail", that is the wrong number in the wrong file: the ledger is the artifact a DPO or auditor actually reads, and it contradicts the evidence it is supposed to seal. A backfilled quarter of reviews all land stamped with the day of the import.
The default path was inconsistent too, just less visibly — the row got
datetime.now().astimezone()and the chain got a second, laterdatetime.now(UTC)reading.Why this is a defect and not the intended design
Three independent signals in the repo say
atmeans event time, not write time:runner.recordalready keeps the contract —runner.py:101isledger.append(event, data, at=run.finished_at).ledger.py's module docstring blesses it explicitly — "Timestamps may be supplied explicitly (imports, backfills, the demo seeder) — the SEQUENCE proves append order either way." Append order is carried byseq, so the stamp is free to be the event time.demo.py:358hand-duplicates the entirefeedback_recordedevent body purely so it can passat=when— routing around the module whose own docstring calls itself "the one place feedback becomes evidence — shared by every entry point" and promises "a Slack click lands the identical store row and ledger event as the CLI, because they call the very same code."Signal 3 is the smoking gun: the seeder had to bypass the shared path precisely because that path could not carry a timestamp.
Fix
One argument —
at=entry.at— plus a comment recording why. The chain is unaffected:seqstill proves append order, andflightdeck audit verifywalks clean.Test
tests/test_feedback.py:test_backdated_feedback_seals_the_event_time_in_the_ledgertest_feedback_without_an_explicit_time_still_seals_what_the_row_saysBoth verified to fail without the fix (
git stashon the source alone) — the second by months, the first by microseconds.Validation
All three CI jobs green locally:
python -m pytest --cov=flightdeck --cov-fail-under=85→ 260 passed, coverage 94.48%ruff check src tests→ All checks passed!flightdeck demo+flightdeck audit verify→ ledger verified, 2,434 entries, chain intactFollow-up (deliberately not in this diff)
With the timestamp forwarded,
demo.py:358's hand-rolled copy of the event body can collapse into arecord_feedbackcall. That is a refactor with its own risk surface (the seeder would start going through the outcome and unknown-run guards), so it belongs in its own PR rather than riding along with a one-line correctness fix.🤖 Generated with Claude Code
https://claude.ai/code/session_01XjLWb6igee2tVTG7wg93Fx