The write target — documents.document_exhibit_marking — already exists (Story-1878, merged): one row per marking event, firm-RLS'd, with exhibit_label, exhibit_party_role + exhibit_party_role_canonical, a lifecycle_status CHECK in (marked, admitted, rejected), and a privilege trigger that blocks a marking on a privileged document. Story-1883 is the AGENT half: Blake reasons over each document and emits typed proposals the broker turns into rows. Blake holds no DB credentials and calls no tool (ADR-128); his brain stays config + soul.
lifecycle_status='admitted' (a tribunal act), and forces a whole-document verdict — so one uncertain attribute would block the two confident ones. The shape that holds: three states PER ATTRIBUTE (proposed · open-question · accepted), proposals as typed intents the broker executes, and two guarantees Blake's code can never break (DR-1 never admits/rejects, DR-2 never labels privilege)."Blake" is agents/registrar/ — he already does classify and govern. Labeling is a third capability built the canonical way (ADR-117): a hand-owned Pydantic AI agent over a FLAT generation DTO (Bug-1357 — DeepSeek drops deep required fields), an output FUNCTION that assembles the rich turn, and a completeness validator that fails-toward-retry.
# agents/registrar/agent.py _label_agent = Agent(name=BLAKE.display_name, output_type=PromptedOutput(_assemble_label_turn), system_prompt=LABEL_SYSTEM, defer_model_check=True) _label_agent.output_validator(_require_grounded_proposals)
The model emits, for each of annexure_label / exhibit_number / party_role, a state restricted to proposed | open-question. A confident exhibit number rides even when the party role is unclear; the unclear one becomes an open-question with a question and no value. accepted is the human act — it is in the wire union but the brain's Literal can't produce it.
# models.py — the brain's emit-state Literal (no 'accepted', no lifecycle word) _LabelEmitState = Literal["proposed", "open-question"] # agent.py — the anti-guess completeness gate def _require_grounded_proposals(turn): # a 'proposed' attribute MUST carry a grounded value (party_role also its canonical token) # else ModelRetry -> the model fills it from the document, or downgrades to open-question
policy.py turns the per-attribute turn into intents. A proposed exhibit number / party role → one propose_exhibit_marking (→ the junction); a proposed annexure label → propose_annexure_label (→ documents); each open-question → create_registrar_review_item (the inbox). Per attribute, not per document.
# policy.intents_for_label — the marking proposal's args {"document_id":…, "proceeding_id":…, "source": "agent", # NEVER recorded_by_user_id — the agent run owns it "lifecycle_status": "marked", # DR-1: never admitted/rejected (a tribunal act) "exhibit_label": "C-1", "exhibit_party_role": "Petitioner", "exhibit_party_role_canonical": "initiating_party"}
The intent stamps policy gated — group-D and LOCKED, so a marking can never ship auto (it's not a name; it's a document identity). The runtime stamps agent_run_id on the turn so an entire run is revertable in one click (revert itself is Story-1884 — not built here). _assert_safe_lifecycle refuses an admitted/rejected status at the boundary even if a future edit tried to set one.
Jurisdictions use different party words; the canonical axis is the same everywhere. The prompt maps the local name onto exactly one token. No DB CHECK on the axis (app-layer — D4), so the closed set is the agent's contract.
| Canonical token | India (IN) display names — prompt-hardcoded |
|---|---|
initiating_party | Petitioner / Plaintiff / Claimant / Complainant / Appellant / Applicant / Prosecution |
responding_party | Respondent / Defendant / Accused / Opposite Party |
adjudicator | The Court / Tribunal / Arbitrator / Bench |
third_party | Intervenor / Impleaded / Witness / Amicus |
The vocabulary is prompt-hardcoded and launched country-by-country (prompts/label.py _JURISDICTION_VOCAB). An unknown jurisdiction is not an error — Blake files every attribute as an open-question and proposes nothing (never a guessed scheme).
The orchestrator checks the canonical privilege_flag first, before any model call: a privileged document yields a skipped_privileged turn with no attributes and no intents. The DB trigger (Story-1878) also blocks it; Blake refuses to even reason about it.
# agent.stream_label_turn if label_input.privilege_flag: # DR-2 — skip upstream, NO model call, NO proposal yield _skipped_privileged_turn(label_input) return
tests/test_registrar_label_capability.py (offline, no model call) covers the three required acceptance cases plus wiring:
| What | Test | |
|---|---|---|
| (a) per-attribute open-question — confident attr proposed, unsure one to inbox, marking carries only the confident | test_open_question_routes_to_inbox_marking_carries_only_confident | pass |
| (b) agent never proposes admitted/rejected — type can't express it; every marking is 'marked'; the guard refuses tribunal acts | test_model_output_cannot_express_admitted_or_rejected, …_marked_never_admitted_or_rejected, …_assert_safe_lifecycle… | pass |
| (c) privileged document skipped — no model call, no proposal | test_privileged_document_is_skipped_with_no_model_call | pass |
| provenance: source='agent', no user id, gated; jurisdiction vocab; manifest wiring | …_provenance_is_agent_run…, …_jurisdiction_vocab…, …_intent_tools_present_in_manifest | pass |
# agents/registrar/ pytest tests/ -q → 226 passed (16 new label tests) ruff check agents/ → All checks passed scripts/check_brain_egress.py · check_prompt_leakage.py → clean
document_exhibit_marking write (pramaan-functions). Blake's job is to propose honestly; everything else is downstream of the wire.