# ACDL Tiered Audit Ledger Design (REQ-20)

> **Status:** design authored in Phase 07 (milestone v1.1); the
> hash-chain + DynamoDB-outbox path is **shipped + production since
> v1.8**. The S3 Object Lock + JWS + async worker + DLQ + daily
> checkpoints build-out is **deferred to a future milestone (D-083)** —
> it requires non-offline-testable AWS infrastructure (Object Lock
> bucket, KMS signing key, SQS DLQ, Lambda worker) and is not in v1.9.

The audit stream is the platform's tamper-evident record of every delivery
action. The vision's "Audit truth lives outside the repository" bet [1]
and "Not a mutable audit log" anti-goal [1] are the binding constraints.
Version-control history does not satisfy regulatory evidence; the ledger
is the source of truth.

## Three tiers

- **Cold tier (source of truth):** S3 with **Object Lock in compliance
  mode**, **7-year retention** (ARCHITECTURE.md §9). No one — including
  root — can delete or overwrite until retention expires. The regulatory
  record. **Deferred to a future milestone (D-083).**
- **Hot tier (query index):** the `acdl-evidence` audit repo (unchanged
  from the v1.0 demo). Not part of the chain; a queryable mirror the
  evidence UI (`evidence-ui/index.html`) reads. Lightweight attestation
  linkage lives in the repo; the regulatory event body lives in S3.
- **Outbox (write path):** DynamoDB, **RPO = 0** (synchronous write before
  contract submission ack). Single-region in v1 (`us-east-1`).
  **Shipped + production since v1.8.**

## Shipped scope (D-041) — production since v1.8

- **DynamoDB outbox:** table `acdl-outbox`, `PAY_PER_REQUEST` (D-044),
  PK `contractId`, SK `eventType#eventTs`, TTL `expire_at` = now + 365d
  (1-year storage per ARCHITECTURE.md §8).
- **`prev_event_hash` chain:** SHA-256 over canonical JSON
  (`json.dumps(event, sort_keys=True, separators=(",", ":"))`), lifted
  from the v1.0 demo's `evidence_writer.py`. Auto-genesis: first event
  has `prev_hash="GENESIS"`.
- **Synchronous write** via boto3 `put_item` (strong-consistent by
  default). No separate async worker / DLQ in v1.9 (RTO = workflow
  re-run).
- **Mirror to `acdl-evidence`:** unchanged from v1.0 — the finalize step
  commits `audit.json` to the evidence repo (the hot tier).
- **Evidence event shape:**
  `{seq, ts, stage, event, prev_hash, hash, contractId, environment, stack, score, band}`.

## Deferred to a future milestone (D-083)

The following build-out was authored as design in Phase 07 and is **not
in v1.9**. It requires AWS infrastructure that cannot be exercised
offline (Object Lock bucket, KMS signing key, SQS DLQ, Lambda worker)
and is deferred to a future milestone. The hash-chain + DynamoDB-outbox
path above remains the v1.9 production audit record.

- **S3 Object Lock:** bucket `acdl-evidence-lock-<account-id>`, Object
  Lock enabled at creation, compliance mode, 7-yr retention
  (`RetainUntilDate` = now + 7y). The outbox→S3 path is an async worker
  that reads from the outbox and writes to Object Lock.
- **JWS detached signature (RFC 7515):** the event payload is
  canonical-JSON-serialized, SHA-256 hashed, signed with a private key;
  the signature is stored *detached* alongside the payload. Signing key =
  **platform-level KMS key** (not per-contract — a per-contract key would
  explode the key-management surface), rotated **quarterly**. The `jws`
  field is added to the event shape when this ships.
- **Async worker + DLQ:** a Lambda (or a Gitea Actions scheduled workflow)
  reads the outbox, writes to S3 Object Lock, signs with KMS. DLQ = an
  SQS dead-letter queue for failed writes. RTO = DLQ replay.
- **Daily checkpoints (§9):** a daily job reads the last event hash and
  writes a "checkpoint" event to the ledger (+ optionally to a public
  notarization service).

## JWS vs chain — orthogonality note

The `prev_event_hash` chain gives ordering/tamper-evidence *within* the
log (a deleted event breaks the chain visibly); JWS gives authenticity
*per event* (a forged event is detectable without re-reading the whole
chain). The chain is shipped (v1.8+); JWS is deferred (D-083). Together
they cover both integrity properties the vision's "Not a mutable audit
log" anti-goal requires.

## Outbox item shape (shipped + deferred fields marked)

- PK `contractId` (UUID).
- SK `eventType#eventTs` (e.g. `POLICY_CHECKED#2026-07-21T12:00:00Z`).
- `payload` (the event body — hash-chained in v1.8+; JWS-signed when
  D-083 ships).
- `prev_event_hash` (chain link; `GENESIS` for the first event).
- `hash` (this event's SHA-256 over canonical JSON).
- `approver_qa` (Gitea/GitHub username of the QA approver; populated on
  qa-promotion by v1.9's `hitl_gates.attest` — D-042).
- `approver_prod` (SRE username; populated on prod-promotion by v1.9's
  `hitl_gates.attest`).
- `approver_dr` (SRE username; populated on dr-promotion by v1.9's
  `hitl_gates.attest`).
- `environment`, `stack`, `score`, `band`.
- `expire_at` (TTL = now + 365d).
- **Deferred (D-083):** `jws` (detached signature), `checkpoint_ref`.

## RPO / RTO table

| Phase | RPO | RTO |
|-------|-----|-----|
| v1.8+ (production, shipped) | 0 (sync outbox write) | workflow re-run |
| Future milestone (D-083) | 0 (sync outbox) | async worker DLQ replay |

## Decision trail

- **D-041** — shipped scope = hash chain + outbox write; Object Lock +
  JWS + worker + DLQ are deferred (D-083).
- **D-044** — outbox mode `PAY_PER_REQUEST`; PK/SK; TTL `expire_at` =
  now + 365d; no separate async worker in v1.9.
- **D-042** — approver identities (`approver_qa`, `approver_prod`,
  `approver_dr`) live in the outbox; the separation-of-duties check
  (`core/separation_of_duties.py`) reads `approver_qa` and compares
  to the prod-dispatch `gitea.actor` / `github.actor`. v1.9's
  `hitl_gates.attest` populates these attributes.
- **D-083** (v1.9) — S3 Object Lock + JWS + async worker + DLQ + daily
  checkpoints deferred to a future milestone. Requires non-offline-
  testable AWS infra.