Skip to content

Counsel review audit trail — what we sign, what we store, how a regulator query is served

Engineering reference for the Plan 97 counsel review evidence shape. Operator-facing how-to lives in docs/runbooks/counsel-onboarding.md; this document is for the engineer answering "how is a counsel approval non-repudiable?" and "what do we hand over when a regulator asks?".

Why the audit trail exists

EU AI Act, RICS, UK Defence AI Playbook, DS 05-138 and Secure by Design each gate certain rule activations on subject-matter-specialist legal review. When a regulator (or our own internal auditor) later asks "prove that counsel X approved rule Y on date Z, on the version of the rule that was active when that verdict was issued", we need to serve a chain of evidence that:

  1. identifies the counsel (name, email, firm, bar registration, jurisdiction),
  2. pins the decision (one of the four labels),
  3. pins the rule version the counsel actually reviewed (sha256 over the canonicalised rule content at request time),
  4. pins the moment in time (decided_at, expires_at, viewed_at), and
  5. is non-repudiable — neither party can credibly claim "that wasn't me / that wasn't what I decided" after the fact.

The Plan 97 schema captures every piece in immutable form. This doc walks the chain field-by-field so an engineer can answer a regulator query end-to-end without reverse-engineering the code.

Schema, evidentially annotated

The two platform-level tables (counsels, rule_review_requests) and the canonical-form audit log together hold every load-bearing field.

counsels

Column Evidential role
id Stable reference target for rule_review_requests.counsel_id. UUID prevents id-guessing in forged audit queries.
framework_id Binds the counsel to a single regulation. Per-framework specialisation is part of the defensibility argument ("a UK property lawyer reviewed the RICS rules; an EU regulatory specialist reviewed the AI Act rules").
display_name Human-readable identifier in the regulator-facing report. Combined with email for unambiguous identification.
email The string that goes into the HMAC payload as counsel_email. Lower-cased and trimmed at write time so the signature payload is stable across submission paths.
firm / jurisdiction / bar_registration Optional but strongly recommended. These three together are what a regulator needs to verify the counsel was qualified to issue the decision (e.g. a UK barrister with a current practising certificate for RICS work).
active Soft-revoke flag. A retired counsel's row stays so historical counsel_id references continue to resolve — the audit trail outlives the contract.
created_at Establishes the contract window. A signature dated before the counsel's created_at would be a red flag.

There is deliberately no UNIQUE constraint on (framework_id, email). A counsel who took a sabbatical and returned can get a fresh row — we preserve the prior row + signatures rather than UPDATE-in-place.

rule_review_requests

Column Evidential role
id Resource id for the whole review chain. Quoted in audit log rows under resource_id.
framework_id Pins the regulatory context. Must equal counsels.framework_id for the assigned counsel — server-side check at request time prevents an EU-AI-Act counsel from receiving a RICS review by mistake.
rule_use_case_ids The exact list of use_case_ids under review. Together with rule_version_hash this answers "what content did the counsel see?" without depending on the YAML files surviving on disk.
rule_version_hash sha256 hex digest over the canonicalised JSON of every referenced rule at request creation time. Computed by rekognise.cp.counsel.hashing.compute_rule_version_hash. The function is deterministic and order-invariant (entries sorted by use_case_id before hashing), so two POSTs of the same rule set produce the same hash.
counsel_id FK to counsels.id. The join is the canonical way to recover the counsel's identifying fields.
status Lifecycle state. CHECK-constrained to the seven legal values — see Status state machine below.
created_at / expires_at / decided_at Three timestamps that together pin the review window. Expiry is enforced server-side (410 Gone on decide after expires_at).
decision_signature HMAC-SHA256 hex digest. The legally-load-bearing artefact. See HMAC signature below.
decision_comment Optional free-text rationale the counsel typed alongside the decision. Stored verbatim.
supplementary_pdf_kv_secret Reference to a Key Vault secret holding a supplementary PDF the counsel uploaded (e.g. their firm's letterhead version of the decision). v1 stores a placeholder string; Azure Blob wiring lands in a follow-up.
requested_by_user_id FK to users.id of the platform admin who initiated the request. Closes the trail on the Secruna side ("who asked counsel X for review?").
nonce 32-hex-char (16 random bytes) per-request value, embedded in the HMAC payload. Replay-resistance: two independent reviews of the same rule batch produce different signatures.
token_hash sha256 of the plaintext magic-link token. The plaintext is shown to the requesting admin once (in the create-response) and never persisted. A DB dump leak doesn't expose live links.

Status state machine

pending ──► approved
         ├─► approved_with_changes
         ├─► changes_requested
         ├─► rejected
         ├─► cancelled       (platform admin withdrew)
         └─► expired         (cron flipped past expires_at)

Every transition out of pending is terminal — there is no approved → rejected recovery path. A flipped-mind counsel issues a fresh review request against the same rules; the audit trail naturally carries both decisions.

Audit log shape

AuditLogEntry rows attached to a counsel review carry:

  • resource_type = "counsel" or "rule_review_request",
  • resource_id = the row UUID,
  • tenant_id = NULL (counsel reviews are platform-level),
  • actor_user_id = platform admin for admin-initiated rows; NULL with actor_type='system' for counsel-facing and cron rows,
  • action = one of the values in the Action vocabulary below,
  • context = JSONB with the decision label, signature, rule version hash, counsel email, magic-link URL (for the requested event), and whatever else is load-bearing for the specific action.

The context JSONB is self-describing: a regulator query against the audit_log alone answers most questions without joining back to rule_review_requests. The join only matters when you need the live status of an in-flight review or want to confirm the signature still verifies against the current secret.

Action vocabulary (stable — these are the strings to grep for):

  • counsel.created / counsel.updated / counsel.deactivated / counsel.reactivated
  • rule_review.requested
  • rule_review.viewed_by_counsel (idempotent — only the first GET fires this; the row is checked-for-presence before insert)
  • rule_review.approved / .approved_with_changes / .rejected / .changes_requested
  • rule_review.cancelled (platform admin)
  • rule_review.expired (cron)

HMAC signature

The decision_signature column carries the HMAC-SHA256 hex digest of:

"{decision}|{counsel_email}|{nonce}|{decided_at_iso}"

under the platform secret bound to COUNSEL_REVIEW_HMAC_SECRET.

What the signature binds

  1. Decision labelapproved / approved_with_changes / rejected / changes_requested. A counsel cannot later claim "I approved with changes, not approved outright" because the signature was computed over the verbatim label.
  2. Counsel email — the same string stored in counsels.email, lower-cased and trimmed. Binds the signature to a person, not just an opaque counsel_id. If the row's email is mutated post- signature, the signature ceases to verify — this is what makes a regulator's verify_decision(...) call meaningful.
  3. Noncerule_review_requests.nonce. Replay-resistance: a replay attacker who copies a (decision, counsel_email, decided_at) tuple from one review can't produce a signature that verifies against a second review's stored nonce.
  4. decided_at ISO timestamp — pins the moment in time. The signature payload uses datetime.isoformat() on a UTC-aware datetime, so the canonical encoding is portable across submitters. sign_decision asserts the datetime carries tzinfo to prevent a stray naive datetime from producing a non-portable payload.

The signing primitive lives in rekognise.cp.counsel.signing.sign_decision. It is intentionally pure (no I/O, no DB, no FastAPI deps) so the same function can be called from cp-api routes, a verification CLI, or unit tests with identical semantics.

Why HMAC and not a digital signature

We considered an asymmetric signing primitive (Ed25519 or RSA-PSS) under which the counsel would hold their own key. We rejected it for v1 because:

  • Operational cost — issuing key material to non-technical counsels (and rotating it, and revoking it) is its own workflow. The magic-link primitive avoids it entirely.
  • Court acceptance shape — UK and EU courts accept "click-to-sign" HMAC-backed evidence for contracts of comparable stakes (DocuSign, Adobe Sign produce the same shape). The non-repudiation argument rests on the unforgeability of the HMAC under the secret-holder's custody, which we satisfy by keeping COUNSEL_REVIEW_HMAC_SECRET in Azure Key Vault and never on a deployed filesystem.
  • Migration path — if a contract counterparty later insists on digital signatures, we add an Ed25519 layer alongside the HMAC and store both. The HMAC stays as the load-bearing artefact for in-flight reviews.

Verification

A regulator-facing verification is a one-shot call:

from rekognise.cp.counsel.signing import verify_decision

ok = verify_decision(
    secret=settings.counsel_review_hmac_secret,
    decision=row.status,                        # the terminal label
    counsel_email=counsel.email,                # lower-cased + trimmed
    nonce=row.nonce,
    decided_at=row.decided_at,                  # UTC-aware
    expected_signature=row.decision_signature,
)

hmac.compare_digest runs the comparison in constant time so a timing side-channel can't probe the digest one character at a time.

Secret rotation

COUNSEL_REVIEW_HMAC_SECRET is provisioned in Azure Key Vault. When the operator rotates the secret:

  • New signatures are produced under the new secret immediately.
  • Existing signatures stay verifiable against the old secret forever — HMAC verification is one-way; we don't need the new secret to verify an old signature.
  • The old secret must therefore be retained in Key Vault for at least the customer-data retention window (currently 7 years). A prematurely-deleted old secret invalidates every historical approval, which is operationally disastrous.

The rotation playbook lives in docs/runbooks/counsel-onboarding.md.

The magic link is the counsel's session. We store only its sha256 hash (token_hash), never the plaintext:

  • Plaintext token is shown once, in the response to POST /admin/rule-reviews (the operator copies it to clipboard and forwards it via their preferred channel).
  • token_hash = sha256(plaintext_token) is what GET /counsel/review/{token} looks up against. The request resolves by re-hashing the request-path token and matching against token_hash.
  • A DB dump leak does not expose live magic links because the hashing is one-way. The operator must re-issue (cancel + create new) to recover.

The 32-byte random source is secrets.token_urlsafe(32) — 256 bits of entropy, well above the guessing-attack threshold. A failed lookup returns 403 (forged token, no information leak) rather than 404 (which would let an attacker enumerate the keyspace by response shape).

End-to-end regulator query: worked example

"Counsel review of RICS AVM rules — show me proof that counsel X approved rule batch Y on date Z."

Step 1 — locate the request:

SELECT *
FROM rule_review_requests
WHERE framework_id = 'rics'
  AND counsel_id = (SELECT id FROM counsels WHERE email = lower(:counsel_email))
  AND status IN ('approved', 'approved_with_changes')
  AND :decided_date::date = decided_at::date;

Step 2 — recover the audit chain for that request id:

SELECT created_at, action, context
FROM audit_log
WHERE resource_type = 'rule_review_request'
  AND resource_id = :request_id::text
ORDER BY created_at;

You will see, in order:

  1. rule_review.requested — context carries the magic-link URL, the list of rule_use_case_ids, the rule_version_hash, and the requested_by_user_id (the platform admin who initiated).
  2. rule_review.viewed_by_counsel — context carries the viewed_at timestamp (counsel opened the link).
  3. rule_review.{decision} — context carries the decision label, decision_signature, decided_at, the rule_use_case_ids again (so the row self-describes without join), and the decision_comment.

Step 3 — independently verify the signature:

ok = verify_decision(
    secret=os.environ["COUNSEL_REVIEW_HMAC_SECRET"],
    decision=audit_row["context"]["decision"],
    counsel_email=audit_row["context"]["counsel_email"],
    nonce=rule_review_requests_row["nonce"],
    decided_at=parse_iso(audit_row["context"]["decided_at"]),
    expected_signature=audit_row["context"]["decision_signature"],
)
assert ok, "signature does not verify — escalate"

If ok is False with the current secret, retry against the previous secret retained in Key Vault from the most recent rotation (see Secret rotation above). If still False, the audit row has been tampered with — escalate to the security oncall.

Step 4 — recover the rule content the counsel actually saw:

from rekognise.cp.counsel.hashing import compute_rule_version_hash

entries = registry.entries_for_frameworks([request.framework_id])
reviewed = [e for e in entries if e.use_case_id in request.rule_use_case_ids]
current_hash = compute_rule_version_hash([e.model_dump(mode="json") for e in reviewed])

if current_hash == request.rule_version_hash:
    # Rule unchanged since approval — approval still applies to the live content.
    ...
else:
    # Rule edited since approval — approval applies to the snapshot
    # captured by `rule_version_hash`; the live content is a different
    # version and would require fresh review.
    ...

The rule_version_hash is what makes the trail defensible against the "the rule was edited after approval" objection. We answer "no, the counsel approved hash A; the current hash is B; here is the diff."

Out-of-scope evidence shapes (deferred items)

  • Digital signature (Ed25519) layer — see Why HMAC and not a digital signature above. Add alongside HMAC if a counsel insists on holding their own key.
  • Plan 78 webhook fan-out for rule_review.decided — counsel reviews are platform-level (tenant_id=NULL) and the webhook subscription model is tenant-scoped, so the fan-out has a domain mismatch. Defer to a follow-up that introduces platform-level webhook subscriptions for "framework partner" listeners.
  • Re-review on rule editsrule_version_hash is stored so a later detection job can compare against current rule content. The detection itself is a follow-up plan; v1's contract is "snapshot the hash, the approval applies to that snapshot."
  • Hard enforcement of approval-before-fire — a rule can fire without an approved review on file. Tightening to "no fire without approval" is a Phase 2 once we've onboarded the first counsel and validated the workflow in production.