Blake's Mode-3 vocabulary approval workflow

Story-1541 · pramaan-functions · pramaan-docs ADR-128 §2/§3/§8 · EPIC-1536

WHY this exists. Blake (the Record Registrar agent) reads a firm's documents and can suggest a brand-new controlled-vocabulary type — a new document_type ("Rejoinder Affidavit") or filing_type ("Reply Affidavit"). If the agent could mint those firm-wide types directly, one hallucinated category would silently pollute every future matter's filters, analytics, and templates — and there would be no human in the loop on a decision that shapes the firm's legal vocabulary. The catastrophe this prevents: an agent quietly editing the firm's canonical taxonomy. So a gated request does not become a live type. It becomes a proposal a human must approve, reject, or merge.

1 · The one-sentence shape

A gated firm-wide vocabulary request lands a non-live "proposed" catalog row (not selectable) plus a pending approval row; a human reviewer (counsel for document types, admin for filing types) then approves it live, rejects it away, or merges it into an existing type — and the broker never had a path to decide for them.

2 · Where it sits — the three layers

This Story stacks on the merged effect ledger (#1542) and broker tools (#1540).

# Blake's brain emits a tool intent; the broker executor is the only path
# to truth. A GATED firm-wide request now routes to the approval workflow.

  brain ──intent──▶ broker executor ──▶ leash · policy · RLS · idempotency
                          │
        GATED + approval ▼  (request_firm_document_type / request_filing_type)
                  ┌───────────────────────────────────────────────┐
                  │ 1. write 'proposed' catalog row  (NOT selectable) │
                  │ 2. record a proposed effect       (custody/ADR-119) │
                  │ 3. open an agent_vocab_proposal   (pending decision) │
                  └───────────────────────────────────────────────┘
                          │
   human (counsel/admin) ▼  POST /app/registrar/vocab-proposals/{id}/{approve|reject|merge}
                  ┌───────────────────────────────────────────────┐
                  │ catalog row → live | deleted   +  effect moves │
                  │ proposal → approved | rejected | merged         │
                  └───────────────────────────────────────────────┘
LayerOwnsTruth it carries
agents.agent_effect (#1542)append-only custody"Blake proposed type X" — never edited, never deleted
agents.agent_vocab_proposal newthe human decisionwho approved/rejected/merged, when, the tier, the justification
business.document_types / filing_typesthe catalogthe type itself — proposed → live | deleted

3 · "Proposed" means present-but-unusable

The catalog gains a new non-live availability value, 'proposed'. The assignable-catalog reads filter to live/provisional, so a proposed type is invisible to assignment — it cannot be silently selected before a human approves it. The live-only partial unique index on lower(name) means a proposed row never collides with the firm-canonical name.

document_types.availability ∈ { live, archived, deleted, provisional, proposed }
filing_types.availability   ∈ { live, archived, deleted, proposed }

4 · The decision state machine

The approval row's lifecycle is the workflow this Story owns:

            ┌──────────▶ approved   catalog row → live;  effect → counsel_accepted
  pending ──┼──────────▶ rejected   catalog row → deleted; effect → rejected
            └──────────▶ merged     catalog row → deleted; merged_into_type_id set
Mapping onto the frozen effect machine (ADR-128 §3). The effect ledger's transitions are pinned by #1542 and were not changed. Approve walks the documented chain proposed → auto_applied → counsel_accepted (applied, then human-accepted). Reject is proposed → rejected. Merge closes the effect as rejected — the standalone new type did not become a new type — while the authoritative merge target lives on the proposal row (status=merged, merged_into_type_id). The decision's who/when stays on the proposal, never on the immutable effect.

5 · Two guardrails that are not optional

Blake cannot self-approve ADR-128 §8

Filing types are admin-only strictness gradient

Filing types shape procedural language, so their bar is highest. A proposal carries an approval_tier derived from its subject (document_type → counsel, filing_type → admin), pinned by a DB CHECK. The decision routes require settings:write, and a filing_type proposal additionally requires the firm_admin role — a firm_editor with settings:write is refused (403).

6 · Reversibility

7 · Create-race guard

Two concurrent registration batches both proposing "Reply Affidavit" must not double-open. A partial unique index on (firm_id, subject_kind, lower(proposed_name)) WHERE status='pending' plus a pre-check means the second request reuses the open proposal instead of duplicating it.

8 · Audit & isolation

9 · What shipped

FileRole
alembic/…/0120_vocab_proposal_approval.pywiden both catalogs to proposed; create agents.agent_vocab_proposal (RLS, decision-shape + tier CHECKs, create-race index)
src/shared/registrar/approvals.pyopen proposal · approve/reject/merge (atomic pending→decided claim)
src/shared/registrar/executor.pyGATED firm-wide request → approval-create path
src/shared/registrar/registry.pyapproval_subject_kind / approval_tier on the two request tools
src/app_api/router_registrar_approvals.pythe human Registrar panel: list · approve · reject · merge (tier-gated)
src/shared/agents_db_models.py, …/dtos.py, audit/registry.py, audit/coverage.pytable + truth model, DTOs, the merged verb, EMIT_ROUTES

Tests

Closes pramaan-io/pramaan-spec#1541. Single alembic head b2d3e4f5a6c7 on a1c2e3b4d5f6. OpenAPI app-api snapshot regenerated. The frozen effect state machine (#1542) and the create_matter_document_type / promote_type_to_firm_catalog tools were left unchanged.