Skip to content

Counsel onboarding & rule review routing

Operator-facing runbook for the Plan 97 counsel review surface. Covers the four operator tasks: register a counsel, request a review, forward the magic link, and read the audit trail.

Engineering-side companion doc: docs/engineering/counsel-audit-trail.md walks the evidence shape (HMAC signature, rule_version_hash, audit log chain) for the engineer answering "how is this defensible to a regulator?".

When you need this

The platform supports concurrent compliance frameworks (EU AI Act, RICS, UK Defence AI Playbook, DS 05-138, Secure by Design). Each framework's rule book needs subject-matter-specialist legal review before customer-facing rules go live. This surface is the workflow we use to commission, track, and record those reviews so a regulator auditing a verdict three years from now can trace "who approved this rule, on what date, against which version".

Counsel review is recommended-but-not-required in v1 — a rule can fire without an approved review on file. Tightening to hard enforcement ("a rule may not classify systems until a counsel approves it") is a Phase-2 follow-up. v1's value is the audit trail, not the gate.

Register a counsel

  1. Sign in to the dashboard as a platform admin.
  2. Navigate to Admin → Counsels.
  3. Click Add counsel and fill in:
  4. Framework — the single framework this counsel is responsible for. A counsel covering multiple frameworks gets one row per framework (the spec's "two rows, one email" rule). Keeps the picker dropdowns trivial.
  5. Display name — what shows on the queue UI and in the audit trail.
  6. Email — the address the magic link gets forwarded to. Lower-cased + trimmed at write time; the same string is bound into the HMAC signature payload, so don't change it later by editing the row in SQL (use the PATCH endpoint instead).
  7. Firm / jurisdiction / bar registration — optional evidence fields. The regulator audit trail returns these alongside the decision signature so a defensibility review can verify the counsel was qualified.
  8. Notes — free-form. Useful for "preferred contact channel: Signal, +44…" type breadcrumbs.

An AuditLogEntry with action counsel.created is written. tenant_id is NULL because counsels are platform-level.

To retire a counsel, open their row and toggle Active off. The row stays in the table so historical review references continue to resolve. A retired counsel can be reactivated with the same toggle — two separate audit actions (counsel.deactivated / counsel.reactivated) so the timeline is greppable.

Request a review

  1. Navigate to Admin → Rule reviews.
  2. Click New review.
  3. Step through the modal:
  4. Framework — picks from the list of frameworks the platform currently ships rules for.
  5. Rules — multi-select of every rule under that framework's rule book. The picker shows a description preview so you don't have to remember rics_avm_due_diligence from rics_avm_lender by id alone.
  6. Counsel — picker is filtered to active counsels registered against the chosen framework. If the picker is empty, the framework has no counsel yet — go back to Counsels and register one first.
  7. Expires in days — default 14 (matches the platform's invitation expiry). Range is 1–60 days; pick shorter for a "please review this week" nudge.
  8. Confirm. The response surfaces the magic link URL with a copy-to-clipboard button. This is the only time the plaintext URL is shown — the database stores only the SHA-256 of the token.

If you lose the URL before forwarding, cancel the request from the queue (it transitions to cancelled) and create a fresh one. There is no "show me the URL again" — by design; the same way a fresh password is shown once.

  1. Forward the URL to the counsel through whatever channel you've agreed with them (email, Slack, Signal, paper letter, smoke signal). Email automation via Plan 76 notification routing is deferred to a follow-up plan; v1 logs the URL via structlog and writes it into the rule_review.requested audit context so an ops grep can recover it. Forward responsibly — the URL is single-use auth for the duration of the magic link.

Server-side, the request creation writes:

  • one rule_review_requests row with status='pending', rule_version_hash set to a sha256 over the canonicalised rule content, a fresh per-request nonce, and the token_hash,
  • one AuditLogEntry with action rule_review.requested and the magic link URL embedded in context so a later operator can re-extract it (yes, the URL is in the audit log — actor_user_id is the platform admin who created it, so the trail is intact).

What the counsel sees

The counsel visits https://app.secruna.io/counsel/review/{token} (or the equivalent staging URL). They see:

  • a header with their display name and the expiry banner,
  • the full content of every rule under review: citations, customer description preview, suggested verdict copy,
  • a decision panel with four buttons: Approve / Approve with changes / Request changes / Reject, a comment textarea, and an optional file upload for a supplementary PDF (e.g. their firm's formal letterhead version of the decision).

On submission the server:

  1. Re-validates the token (404/410 on tampered/expired),
  2. Computes the HMAC-SHA256 signature of "{decision}|{counsel_email}|{nonce}|{decided_at}" using the server's COUNSEL_REVIEW_HMAC_SECRET,
  3. Persists decided_at, decision_signature, decision_comment, and flips status to one of the four terminal values,
  4. Writes an AuditLogEntry action rule_review.{decision} (e.g. rule_review.approved).
  5. Returns the signature to the counsel so they can screenshot / save it for their own records — same evidence shape DocuSign returns.

The first time a counsel GETs the page (before deciding), a rule_review.viewed_by_counsel audit row is written so we can prove they at least opened the link. Subsequent GETs are silent (idempotent).

What the HMAC signature represents

The decision_signature column carries the HMAC-SHA256 hex digest of the payload "{decision}|{counsel_email}|{nonce}|{decided_at_iso}", under the platform secret COUNSEL_REVIEW_HMAC_SECRET.

For a regulator audit, the signature is the non-repudiable component of the trail:

  • Only Secruna holds the secret, so a third party can't forge a signature.
  • The counsel can't credibly claim "I didn't decide that" because the signature verifies against their stored email and the request's nonce.
  • A re-verifier (re-running sign_decision(...) against the same inputs) produces the same digest only if every field is byte- identical.

The secret rotates: when a new COUNSEL_REVIEW_HMAC_SECRET is provisioned, all existing signatures stay valid against the old secret (HMAC verification is one-way; you don't need the new secret to verify a signature created under the old one — keep the old secret on file in Key Vault for at least the customer-data retention window).

Production handoff

The HMAC secret is a new Key Vault binding the operator must provision before merging Plan 97. The env-var is COUNSEL_REVIEW_HMAC_SECRET. Dev / CI default is dev-secret-do-not-use-in-prod so a misconfigured prod deploy fails an end-to-end signature comparison loudly. Steps:

  1. Generate a fresh 32-byte secret: python -c "import secrets; print(secrets.token_hex(32))".
  2. az keyvault secret set --vault-name <prod-kv> --name counsel-review-hmac --value <secret>.
  3. Bind COUNSEL_REVIEW_HMAC_SECRET on the cp-api and discovery-worker container apps to the Key Vault secret reference.
  4. Restart cp-api. The new secret takes effect immediately for new reviews; existing reviews already carry a signature derived under whatever secret was active at the time.

Magic links expire 14 days after creation by default (configurable per request, 1–60 days). Once expired:

  • The GET /counsel/review/{token} endpoint returns 410 Gone so the frontend can render "this review request has expired" rather than "access denied".
  • The POST /counsel/review/{token}/decide endpoint returns 410 so a cached form-submit can't write a stale decision.

A daily cron (discovery_worker.counsel_review_cleanup) sweeps the rule_review_requests table for status='pending' AND expires_at < now() and flips them to status='expired', writing a rule_review.expired audit row per flip. The cron's bicep stanza is deferred to a follow-up (the Python entrypoint ships; the cron infra itself doesn't trigger until applied separately).

To re-request a review after expiry simply create a new review for the same rules and counsel. The fresh request gets a fresh magic link, a fresh nonce, and a fresh rule_version_hash computed against the current YAML — so an edit between the expired request and the new request is captured naturally.

Audit trail quickref

Every counsel-related action writes an AuditLogEntry with a stable action value. To grep the trail for one counsel's history:

SELECT created_at, action, context
FROM audit_log
WHERE resource_type IN ('counsel', 'rule_review_request')
  AND context->>'counsel_id' = '<counsel-uuid>'
ORDER BY created_at;

Action vocabulary:

  • counsel.created / counsel.updated / counsel.deactivated / counsel.reactivated
  • rule_review.requested
  • rule_review.viewed_by_counsel (idempotent — only the first GET fires the event)
  • rule_review.approved / .approved_with_changes / .rejected / .changes_requested
  • rule_review.cancelled (platform admin withdrew)
  • rule_review.expired (cron)

The audit row's context JSONB carries the decision, signature, and rule_version_hash so the trail self-describes — you don't need to join back to rule_review_requests to answer most regulator questions.

Out of scope for v1 (defer items)

  • Email automation — Plan 76 notification routing is the proper home for "send the magic link via SMTP". v1 logs the URL via structlog so the operator forwards manually. When Plan 76 lands, swap the structured log for a notifier.send(...) call in the rule_review.requested audit emission.
  • Webhook fan-out — Plan 78 webhooks. The rule_review.decided event shape is reserved but not yet emitted via the subscription delivery path. Defer the wiring to a follow-up.
  • Hard enforcement — a rule may currently fire without an approved review. Tightening to "rule cannot fire without approval" is a Phase 2 of this plan once we've onboarded a first counsel and validated the workflow holds up.
  • Multi-counsel quorum — one counsel per request is the contract. Second-opinion review = a fresh request to a second counsel; the audit trail naturally carries both signatures.
  • Re-review on rule editsrule_version_hash is stored so a later detection job can compare against current content; the detection itself ships in a follow-up.