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:
- identifies the counsel (name, email, firm, bar registration, jurisdiction),
- pins the decision (one of the four labels),
- pins the rule version the counsel actually reviewed (sha256 over the canonicalised rule content at request time),
- pins the moment in time (decided_at, expires_at, viewed_at), and
- 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;NULLwithactor_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.reactivatedrule_review.requestedrule_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_requestedrule_review.cancelled(platform admin)rule_review.expired(cron)
HMAC signature¶
The decision_signature column carries the HMAC-SHA256 hex digest of:
under the platform secret bound to COUNSEL_REVIEW_HMAC_SECRET.
What the signature binds¶
- Decision label —
approved/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. - Counsel email — the same string stored in
counsels.email, lower-cased and trimmed. Binds the signature to a person, not just an opaquecounsel_id. If the row's email is mutated post- signature, the signature ceases to verify — this is what makes a regulator'sverify_decision(...)call meaningful. - Nonce —
rule_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. - 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_decisionasserts 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_SECRETin 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.
Magic-link token storage¶
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 whatGET /counsel/review/{token}looks up against. The request resolves by re-hashing the request-path token and matching againsttoken_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:
rule_review.requested— context carries the magic-link URL, the list ofrule_use_case_ids, therule_version_hash, and therequested_by_user_id(the platform admin who initiated).rule_review.viewed_by_counsel— context carries theviewed_attimestamp (counsel opened the link).rule_review.{decision}— context carries the decision label,decision_signature,decided_at, therule_use_case_idsagain (so the row self-describes without join), and thedecision_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 edits —
rule_version_hashis 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.