Story-1541 · pramaan-functions · pramaan-docs ADR-128 §2/§3/§8 · EPIC-1536
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.
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.
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 │ └───────────────────────────────────────────────┘
| Layer | Owns | Truth it carries |
|---|---|---|
agents.agent_effect (#1542) | append-only custody | "Blake proposed type X" — never edited, never deleted |
agents.agent_vocab_proposal new | the human decision | who approved/rejected/merged, when, the tier, the justification |
business.document_types / filing_types | the catalog | the type itself — proposed → live | deleted |
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 }
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
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.
approve_* tool is registered — it is on the LEASH_FORBIDDEN_TOOLS list, so the broker rejects it as an unknown tool. There is no agent path to a decision.require_permission); the approver id is the authenticated user, never client- or agent-supplied.
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).
availability='deleted' — the ADR-041 retention lifecycle, never a hard session.delete() of a firm-owned row. It is retained for audit, gone from the catalog.auto_applied → undone edge, so any reversible Blake apply stays reversible (proved by a test).promote_type_to_firm_catalog stays a distinct human-gated admin action requiring cross-matter recurrence — it is not this approve path and was left untouched.
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.
document_type.approved / .rejected / .merged (and the filing_type.* variants) — in the same transaction as the catalog mutation, satisfying the route-coverage manifest and the flush watchdog.agent_vocab_proposal is firm-RLS isolated, append-only (broker may SELECT/INSERT/UPDATE, never DELETE), and registered in the support-confidential table list — a firm can never see another firm's proposals, and a cross-firm decision is invisible (404).| File | Role |
|---|---|
alembic/…/0120_vocab_proposal_approval.py | widen both catalogs to proposed; create agents.agent_vocab_proposal (RLS, decision-shape + tier CHECKs, create-race index) |
src/shared/registrar/approvals.py | open proposal · approve/reject/merge (atomic pending→decided claim) |
src/shared/registrar/executor.py | GATED firm-wide request → approval-create path |
src/shared/registrar/registry.py | approval_subject_kind / approval_tier on the two request tools |
src/app_api/router_registrar_approvals.py | the human Registrar panel: list · approve · reject · merge (tier-gated) |
src/shared/agents_db_models.py, …/dtos.py, audit/registry.py, audit/coverage.py | table + truth model, DTOs, the merged verb, EMIT_ROUTES |
tests/unit/test_registrar_vocab_proposal.py — registry wiring, migration shape, tier coherence, leash (no self-approve), audit actions, DTO round-trip.tests/integration/test_registrar_vocab_approval.py — real DB, real RLS: lands proposed (not live, not assignable), approve→live, reject→deleted, merge→mapped, dedup, live-exists conflict, reversibility, cross-firm isolation.tests/integration/test_app_registrar_approvals.py — HTTP: 401 unauth, counsel approves a document type (+ audit row), filing-type admin-only (editor 403 → admin 200 + audit), member 403.
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.