Skip to content

docs(adr): options memo for audit_log retention levers (BACKLOG #1421) - #933

Open
wshallwshall wants to merge 1 commit into
mainfrom
builder-1421-audit-memo
Open

docs(adr): options memo for audit_log retention levers (BACKLOG #1421)#933
wshallwshall wants to merge 1 commit into
mainfrom
builder-1421-audit-memo

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

What this is

BACKLOG #1421's remaining limb is one owner ruling: does deleting an audit row break the
tamper-evident chain, and therefore which retention lever exists at all
for an audit_log that
would grow by one commit per authenticated read on a first deployment.

This ships ADR 0185 as an options memo, Status: Proposed. It lays the levers out with their
costs so the owner can rule. It picks nothing, and it changes no engine behaviour -- no purge, no
new config field, no default moved. [retention].audit_days is still accepted and unenforced.

Row 1421 stays OPEN. Its banner records that the memo exists and that the ruling is outstanding.
Read with parse_items after the edit: is_open True, 1 open banner, 0 closed.

The mechanism, re-read at HEAD rather than inherited

Row 1421's cited line numbers are from fd44b0f17 and have drifted. These are current.

  • audit_row_hash (store/store.py:1009) digests [prev_hash, ts, actor, action, channel_id, detail] at :1057, plus client as a conditional seventh element. The row id is not in the
    payload.
  • record_audit (:7603) reads the head under the store lock immediately before the insert, so
    chain order is id order and nothing else. row_hash is a plain nullable TEXT column (:1782).
  • The walk chains from the STORED hash (:7998), not the recomputed one. That one line decides
    most of the question.
  • A verifier exists, three ways: the Store protocol (store/base.py:1543), the CLI
    (__main__.py:617, :646), and [integrity].audit_verify_on_start, which ships False
    (config/settings.py:3370). It returns one boolean and names only the first divergent row
    (:8008).

What was measured

Eight shapes driven at c57903c2c: a throwaway SQLite store in a temp directory, six rows written
through the real MessageStore.record_audit, each shape applied out-of-band, verify_audit_chain
re-run. Synthetic actors only, no PHI, nothing written into the repository. Both controls fired --
a clean store verified, an ordinary interior edit broke. A second full walk ran beside the shipped
verifier, because the shipped one reports only the first break.

Cases A, B and D reproduce row 1421's existing results. Three findings are new:

  1. A break is local and does not spread. Only the touched rows mismatch; their successors verify
    and keep their evidentiary value.
  2. An interior delete leaves the anchor head byte-identical. Only the count moves, so an anchor
    catches that shape by count and not by hash.
  3. A tombstone preserving the stored row_hash still breaks the walk at the tombstoned row. So
    preserving the link bounds the damage without removing it.

The crux for the ruling: delete-then-reseal verifies clean. That is what an attacker who can
write the table would produce, so a re-sealing purge would leave a held off-box anchor as the only
working control.

What the three merged PRs already settled

Checked by content against origin/main, not by git merge-base --is-ancestor -- this repository
squash-merges and deletes branches, so ancestry returns false for work that landed.

  • 766 -- costs 3 and 4. api/security.py:175-188 carries the replacement reason. Note the word
    "inventing" is still in the file, quoted as the retired reasoning it corrects, so a bare grep for it
    reads as "the fix did not land", which is the wrong answer.
  • 775 -- the deletion-shape measurement, in row 1421.
  • 805 -- the rationale reconciliation. docs/CONFIGURATION.md's audit_days row is the source of
    record; settings.py:1675-1683 and PHI.md link to it.

Severity

Conditional per CLAUDE.md section 0. Zero deployments, so nothing is growing, and no operator is
affected today. A first deployment would grow audit_log unbounded at one row per authenticated
read, with no configured window able to stop it.

Checks

Check Result
ruff check . passed
ruff format --check . passed, 1256 files
mypy messagefoundry (strict) passed, 268 source files
pytest docs/ledger slice 438 passed -- test_backlog_status_check, test_ledger_check, test_backlog_citation_check, test_adr_analyze, test_backlog_score_advisory, test_backlog_hygiene_claim_extraction, test_doc_guards_lane, test_backlog_dependency_census, test_docs_cite_no_refused_config_keys
pre-commit at commit all hooks passed, ledger gate included

Skipped: the full suite. This change is docs only -- three files, all under docs/ -- so no
engine path is exercised by it. The two warnings in the docs slice are pre-existing baseline rows for
master-test-plan documents absent from this checkout, unrelated to this change. Hosted-runner-only
legs were not seen from this session and must be read on the PR.

Notes for the reader

  • ADR 0185 was allocated with alloc.ps1 to this worktree, and its index row is in the same
    commit
    . No number is cited that was not allocated.
  • The Builder recommendation in the ADR is marked as the Builder's and is separable from the findings.
    In one line: make the anchor operational first, take a write-time bound as the primary lever, use
    archive-first with the case C1 contract if the table itself must be bounded, and reject both hard
    delete and re-seal.
  • Auto-merge is not armed.

Generated with Claude Code (https://claude.com/claude-code)

ADR 0185, Proposed. #1421's last limb is one owner ruling: which retention
lever exists for a table that must stay tamper-evident. This lays the options
out and picks none.

Mechanism re-read at HEAD, not inherited: the row id is not in the hashed
payload, and the walk chains from the STORED hash. A verifier exists three
ways and ships off. Eight shapes driven with both controls firing, plus a
full walk beside the shipped one, which reports only the first divergent row.

Three findings are new: a break is local and does not spread; an interior
delete leaves the anchor head byte-identical; a tombstone keeping row_hash
still breaks at that row. Crux: delete-then-reseal verifies clean, so only a
held off-box anchor tells a purge from an attack.

Docs only. No purge, no config field, no default moved, row stays open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant