Blake batch registration run — summary, exceptions & undo

After a multi-file upload Blake processes the whole batch, not file-by-file. He folds every per-document outcome into one summary card (the §5 counts table), surfaces only the exceptions that need counsel judgment — clustered, so a new filing type seen on 9 files is one approval, not nine — guarantees the possible-privileged bucket always reaches a human, and offers a single Undo batch that reverses everything Blake auto-applied.
Story-1548 · EPIC-1536 · agents/registrar/ · branch issue-1548-blake-batch-run · 2026-06-21

Classify (#1538) proposes a registration per document; Govern (#1539) gates new vocabulary; the eval gate-rule (#1544) keeps every business mutation on suggest until proven. This Story adds the layer ABOVE the single document: the batch run. The brain stays proposal-only and broker-mediated (pramaan-docs ADR-128) — Blake aggregates and proposes the summary / review-routing / undo effects; the broker persists the card and flips the effect ledger. No model call is involved: batch aggregation is deterministic Python over the per-document turns the classify pass already produced, which is exactly what makes a re-run idempotent.

What to review

AreaWhereWhat it does
The §5 foldagents/registrar/batch.pyDeterministic aggregator (mirrors govern.py): folds per-document turns into the counts table + exception clusters + partial-failure note. Pure, offline, no model.
Typed contractsagents/registrar/models.pyRegistrarDocumentResult, RegistrarBatchCounts, BatchExceptionCluster, RegistrarBatchSummary, the undo input/output, and AgentEffectRecord (the ledger row Blake reasons over).
Broker intentsagents/registrar/policy.pyintents_for_batch_summary + intents_for_undo_batch, and _system_effect_policy — the group-G exemption that lets a system effect ship auto while a business mutation never can.
Orchestratorsagents/registrar/agent.pysummarize_batch / undo_batch — span-wrapped, deterministic, assemble the output + its intents.
Runtime modesagents/registrar/runtime.pyNew summarize / undo_batch modes emit honest agent_event_stream.v1 frames (run.started → model.usage → turn.completed → run.completed), zero tokens.
Teststests/test_registrar_batch.py19 offline tests: the 120-file card, cluster-not-per-file, privilege-always-reviewed, partial-failure visible, idempotency, undo reverses only reversible auto effects, custody never touched, runtime frames. NO key.

One card per batch — the §5 counts table

A 120-file upload yields exactly one RegistrarBatchSummary. The counts are a per-document fold; new_document_types / new_filing_types count distinct proposed values (one per cluster), not documents, so the card reads "1 new filing type (9 docs)".

# batch.py — the deterministic fold (excerpt)
for result in results:
    if _is_failed(result):
        counts.failed += 1
        continue
    proposal = result.turn.proposal
    if proposal is not None:
        if proposal.display_name.strip(): counts.renamed += 1
        if proposal.tags:                 counts.tagged  += 1
        if proposal.document_type:        counts.typed   += 1
    if result.turn.duplicate_candidate:   counts.duplicate_candidates += 1
    if result.turn.privilege_candidate:   counts.possible_privileged  += 1
    if result.turn.verdict == "needs_review": counts.low_confidence   += 1
Why a pure fold. The acceptance criterion is "re-running is idempotent — no duplicate work." A summary that is a function of its inputs gives that for free: re-aggregating the same batch is byte-identical, and write_registration_summary is keyed by batch_id so the broker upserts ONE card.

Exceptions are clustered — one judgment, not N prompts

The card surfaces only the exceptions needing counsel judgment, grouped so counsel acts once. New vocabulary clusters by proposed label (batch_confirm — a single one-click approve); privilege, duplicate, and low-confidence cluster by flag (human_review).

Cluster kindResolutionCounsel action
new_filing_typebatch_confirm"Found 9 Reply Affidavits — add?" — one click for the whole cluster.
new_document_typebatch_confirmConfirm the coined matter-provisional type once.
possible_privilegedhuman_reviewAlways to a human — never auto-set, never auto-cleared.
duplicate_candidatehuman_reviewSurfaced for merge/keep — dedup never destroys.
low_confidencehuman_reviewRouted to review rather than guessed.
The procedural guarantee. The possible-privileged cluster is built whenever any document carries the flag, independent of Blake's confidence. Privilege can be over-flagged but never silently dropped — counsel reviews the entire bucket regardless of recall (the ADR-128 leash; set_privilege is a forbidden tool).

The system-effect exemption — why the summary may ship auto

The classify/govern intent path (_policy_for) hard-refuses any auto intent: business mutations stay suggest until their eval gate flips them (#1544). The batch effects are different — they are group-G system effects (Blake's own ledger/status writes, not firm vocabulary), which the gate-rule exempts.

# policy.py — the exemption is narrow, and fails loud if abused
def _system_effect_policy(tool):
    policy, system_effect = _load_system_effect_table()[tool]
    if policy == "auto" and not system_effect:
        raise ToolPolicyError(...)   # auto is NEVER a back door for a business mutation
    return policy

So write_registration_summary / create_registrar_review_item / complete_registration_batch ship auto (system effects), and undo_agent_effect ships gated (counsel-initiated). The underlying applies — names, tags, types — are untouched by this Story and stay suggest.

Undo-batch — reverses only what Blake auto-applied, idempotently

Counsel can revert the whole batch. Undo reverses only the reversible auto_applied effects for a batch_id; an effect that was never applied, is already undone, was confirmed by counsel, is non-reversible, or names a custody tool is left in place.

# policy.py — the leash, restated as a filter
reversible = (effect.state == "auto_applied"
              and effect.reversible
              and effect.tool not in _NEVER_UNDO_TOOLS)   # set_privilege / mark_filed / delete_document …
Idempotent by construction. Re-running undo on an already-undone batch reverses 0 effects (every row is in state undone → skipped). The custody guard is defense-in-depth: Blake never auto-applies a final-privilege / filing-state / destructive effect, so those never reach auto_applied — but the filter makes the boundary explicit and tested.

Partial failure is surfaced, never silent

Per-document isolation means one bad file doesn't sink the batch. Failures are counted and surfaced:

11 of 120 failed  →  failure_note = "11 failed, retrying",  batch_status = "partially_done"
all failed         →  batch_status = "failed"
none failed        →  batch_status = "done"

Proof

$ cd agents/registrar && pytest tests -q
196 passed   # 19 new batch tests + 177 prior, all offline, no key
$ ruff check .            # All checks passed!
$ pytest tests/ -q -m unit   # repo governance (incl. brain-egress)
101 passed

The brain-egress gate confirms the new code adds no DB/S3/SQS/HTTP dependency — Blake still writes no canonical truth and holds no credentials. The summary, review-routing, and undo are typed proposals the broker executes.