Persist review evidence and deliver audit records transactionally

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-10 23:27:19 +02:00
parent 0e48355b9f
commit 2cc32168ac
14 changed files with 1474 additions and 22 deletions

View file

@ -97,11 +97,13 @@ The client is an internal seam, not a sufficient binding flow. Before a browser
route can call it, T08 must connect:
1. The access-engine entitlement decision before rendering a named memo.
2. A durable presentation/version, actor match and required acknowledgments.
3. Dispositions and a transactional evidence outbox, including return/discuss.
2. Wire the durable presentation/version, actor match and required acknowledgments.
3. Wire persisted dispositions and the transactional outbox, including return/discuss.
4. Independent audit custody/delivery and entry-correlation reconciliation.
5. Native registered KeyCape login and the deployed Approval Engine proof.
The browser currently exposes no memo or entry route and no consume capability.
The current `evidence.Outbox` is in memory and cannot satisfy durable evidence
requirements. These remaining items stay in the active T08 record.
The legacy `evidence.Outbox` is an in-memory test double. `Store` now supplies
durable atomic state/evidence, and `audit.py` supplies delivery to the real
receiver contract; see [durable-review-evidence.md](durable-review-evidence.md).
Native custody, policy/controller integration and live admission stay in T08.

View file

@ -0,0 +1,144 @@
# Durable review evidence and audit delivery
Implemented under `INFD-WP-0001-T08`. `store.py` replaces the domain tests'
in-memory outbox with a persistent internal store. It does not expose a browser
route, decide entitlement, or authorize an approval entry. `web.py` still has
no memo/entry routes and `/readyz` remains 503.
## Storage and recovery
The store uses a private SQLite file (0600), inside an existing directory owned
by the process user (0700). Symlinks, hard-linked databases, unsafe permissions,
unrecognized databases and unsupported schema versions are refused. WAL,
foreign keys and FULL synchronous commits are enabled. Connections are scoped
to operations; concurrent writers serialize through SQLite transactions.
This is a local Unix filesystem design, not shared network storage.
The existing domain objects are serialized to JSON, without pickle or a second
Decision Memo schema. Memo versions, packet documents, original presentations,
acknowledgments, dispositions and evidence rows are immutable. SQL triggers
reject ordinary updates/deletes; they do not protect against a database
administrator or a compromised process with file access.
Packet bytes must exist in the store before a memo can reference their SHA-256
hashes. Presentation content includes the memo and the exact binding/awareness
documents. A presentation is produced only through `presentation.render`, the
existing writer of `view_hash`. Packet contents are checked against their
hashes on custody retrieval. A retrieval failure names Informed Decision as
the custodian that cannot produce the material; it never returns a blank as
though no evidence had been promised.
Every presentation, explicit acknowledgment, disposition and submission result
commits its evidence and outbox row in the same local transaction. An outbox
write failure rolls the state change back. Acknowledgments append separate
records; they never overwrite the original presentation. A disposition retains
the acknowledgment snapshot that existed at that act, so later acknowledgment
does not retroactively strengthen its evidence.
Actor and presentation recipient must match. Binding also requires verified
human provenance, the current memo version, all required acknowledgments and
the existing step/seal guards. An accept intent requires the native approval id
and carried digest. Return, discussion and decline remain distinct local acts;
only accept creates an external-submission record. A dependency outage is a
stance application, never an invented human decline.
`Store.backup(new_path)` uses SQLite's backup API; it refuses an existing
destination. Restoring the resulting private database preserves evidence,
outbox ids and submission state. Tests cover process death before commit,
reopening after commit and restoring pending/confirmed records. An admitted
production backup location, schedule and restoration drill are still owed.
There is no automatic deletion/retention job in this implementation.
## Approval submission and uncertainty
These are internal delivery records, not cached approval validity:
1. Record the human's accept intent and its evidence before an engine POST.
2. The controller must obtain a fresh applicable access-engine decision, verify
the session and compare the memo's carried digest with the live approval.
**That controller/policy integration is still pending.**
3. `begin_submission` atomically reserves one attempt. A stale presentation
cannot start; a memo cannot be revised while its entry attempt is in flight
or unresolved. Network I/O happens outside the SQLite transaction.
4. A successful non-duplicate entry response is correlated to the original
disposition using `(approval_id, subject, approved_at)`, atomically with an
evidence record. This confirms delivery, not permission to execute the act.
Operation ids make the same click idempotent; reuse with different content is
refused. A second presentation cannot acquire another intent for the same
approval and approver. A known confirmed correlation retrieves the original
presentation and acknowledgment snapshot.
**An unknown duplicate or lost response is unresolved.** It may refer to an
entry created before this presentation. The engine discards the POST body and
does not store a caller's presentation/operation id, so finding an entry later
does not prove which presentation caused it. The store does not attach it to
the latest view or automatically POST again. A crash after reservation likewise
does not reopen dispatch. The records remain visible for explicit controller/
operator recovery; no recovery path in this slice fabricates that missing link.
The remaining T08 controller must handle this state visibly before shipping.
## Audit Core contract
`audit.py` sends the envelope stored at event creation, with stable event id,
`Idempotency-Key`, source `informed-decision` and tenant `tenant:platform`.
The sender credential is supplied by an owner-provided callback, remains out of
the database and is never sent to a redirected endpoint. No native credential
is provisioned or read by this implementation task.
The receiver must return exactly `202 accepted` or `200 duplicate` with
`reference=audit:<event-id>` before local delivery is marked complete. Lost
responses replay the exact bytes; Audit Core deduplicates them. A 30-second
local lease bounds concurrent claims; stale workers cannot overwrite a newer
delivery result. Retryable failures back off to a five-minute maximum.
Malformed events, conflicts and credential refusals remain visibly blocked;
an explicit requeue after repair preserves the original event id and body.
No failure drops the evidence or stores the upstream response body as an error.
Only commitments and metadata travel: no brief, packet content or discussion
note. Native sender custody, receiver registration/application and independent
operator access still need live proof. Component tests use Audit Core's actual
ingestion/read APIs with a distinct SQLite fixture and synthetic scoped sender
and auditor identities. That is contract proof, not production custody proof.
Per-class heartbeat generation uses the existing 86,400-second declarations.
It does not emit “nothing to report” for a class with undelivered evidence.
The host must schedule heartbeat generation, bounded draining and monitoring;
no background service or cadence is installed by this source change.
## Reconciliation has two time bases
Audit Core's current `/v1/reconciliation` counts rows by **accepted_at** and
returns `[{"class": ..., "count": ...}]`. The source counts events by
**occurred_at**. A delayed delivery can fall into different windows even when
nothing is missing. The client verifies source, tenant, explicit time window and
count shape, and reports both time bases with `count_values_match`.
It deliberately sets `automatic_loss_finding=false`, `completeness_proven=false`
and `reconstructability_proven=false`. Compare stable catch-up totals and inspect
pending/blocked events and receiver references before interpreting divergence.
A production reconciliation procedure that accounts for delayed acceptance is
still part of T08 admission. Equal counts cannot establish equal membership,
complete emission, truthful presentation or availability of source-held content.
The GH-DEC-2026-014 commitment-only limitation remains.
## Verification and next integration
```sh
INFD_APPROVAL_ENGINE_SOURCE=/home/worsch/approval-engine \
INFD_AUDIT_CORE_SOURCE=/home/worsch/audit-core \
uv run python -m pytest -q
```
258 tests pass: 52 added tests cover transactional rollback, process death,
restore, actor/version/ack guards, concurrent clicks, original correlation,
ambiguous submissions, audit retry/refusal and actual receiver contracts.
The opt-in suites use the actual Approval Engine and Audit Core implementations
with synthetic identity/custody. They make no policy decision and spend nothing.
Remaining under T08: an admitted PDP package/caller and exact read/bind request,
protected review/ack/accept/return/discuss routes, durable policy observations,
visible unresolved-entry recovery, scheduled audit delivery/reconciliation,
native registered human login and deployed-engine/custody proof. No Informed
Decision policy package or registration was found in the checked Flex Auth
examples, registry and docs at `88b3543`; do not substitute a local allow rule.

View file

@ -0,0 +1,60 @@
{
"schema": "informed-decision.durable-review-evidence.v1",
"observed_at": "2026-09-10T21:10:46.040050+00:00",
"base_commit": "0e48355b9fcb28ccd0ba09398ee5ac4311e66011",
"contract_source_commits": {
"approval-engine": "a0a602976eef818f36dde35f76f7f2e589bd051b",
"audit-core": "5c0ad522fb36092aa7ec2e8d72f63a5a91853b5b",
"flex-auth": "88b354377c8e26b162f1234e673072f1c06dcd89",
"key-cape": "139994cfac28ff97163ce4bf263f2bb035bbe0a7"
},
"source_task": "INFD-WP-0001-T08",
"source_task_status": "progress",
"verification": {
"command": "INFD_APPROVAL_ENGINE_SOURCE=/home/worsch/approval-engine INFD_AUDIT_CORE_SOURCE=/home/worsch/audit-core python -m pytest -q",
"tests_passed": 258,
"tests_failed": 0,
"tests_skipped": 0,
"new_tests": 52,
"actual_api_component_tests_total": 7,
"actual_api_component_tests_new": 4,
"identity": "locally signed synthetic fixture",
"audit_custody": "actual Audit Core SQLite fixture, separate sender/auditor identities",
"native_policy_exercised": false
},
"implemented": [
"private persistent SQLite review/evidence store with immutable versions and packet custody",
"atomic presentation, explicit acknowledgment, disposition, submission result and outbox writes",
"actor-recipient, current-version and verified-human provenance checks",
"single-attempt entry reservation and original presentation correlation",
"explicit unresolved state after lost entry response or unknown duplicate; no automatic entry retry",
"Audit Core exact-byte idempotent delivery with bounded retries and retained blocked records",
"explicit backup/restore, heartbeat generation and bounded two-time-base reconciliation"
],
"limits": [
"internal store only; no new public approval routes",
"controller must obtain native PDP decision and validate session and live carried binding digest before render/dispatch",
"unknown entry causation remains unresolved; engine stores no caller presentation/operation id",
"heartbeats and outbox delivery require host scheduling and monitoring",
"source counts occurred_at while Audit Core counts accepted_at; equality does not prove completeness or reconstructability",
"no native sender custody or registration/login proof",
"no native PDP policy package found in checked Flex Auth examples, registry and docs; owner admission still required"
],
"source_sha256": {
"informed_decision/records.py": "82f696151895c51b168144a3078d05764da3499b96917c465629ce0836fa8e33",
"informed_decision/store.py": "a26fffe647b866b061d40aec8ec4f1754d9a8df167973ccdd30ae5dfdb4d7ed4",
"informed_decision/audit.py": "06da36a8f3451b4abc7a46da2df66d5939524c170658b879499b2a47a1b10cd3",
"informed_decision/disposition.py": "5ddd19c2444f8e6c9bb090d04045cf1227a34554e000f07b3f677325bf8089f8",
"informed_decision/evidence.py": "5f84997f66409c7b6cec6ab57e73ad5bba2c1b2d669604adcbf36e72a824a88d",
"tests/test_durable_store.py": "e69b8c6c7dfe30291ed1eab2397f3740aa128a38da7c644894e7d04f3f365a29",
"tests/test_audit_delivery.py": "c5f8a35335393d74739cd21c1a4d5b5306b30dfd16966d223119d087c18d4fe0",
"tests/test_durable_component.py": "7f9e7b5075f7e66104b2b634d5bb14556bf4ff23e7060aae166e3697dc138c29",
"tests/test_skeleton.py": "1b9ab97c166870dca24c34149d5f9214ec186de31787407014cbb2342acb1d92"
},
"browser_approval_routes_exposed": false,
"deployed": false,
"factory_attempts": 0,
"paid_model_calls": 0,
"remaining_live_task": "INFD-WP-0001-T08",
"factory_gate_task": "HFACT-WP-0001-T03"
}