Story 2276: the wire between the brain and the border — B2b segmentation wiring
WHY. B1 (Story-2271) built the tables; B2a (Story-2272, pramaan-agents #214) taught Blake
to draw sections — byte-verified (page, block) boundaries over the anchored pages
rendering. Neither connects to the pipeline. This Story is serial link 3: the functions half that INVOKES
Blake's segment command from the registration fan-out, PERSISTS the
document_sections.v1 artifact, gives the taxonomy its two broker doors
(list_section_kinds / propose_section_kind), wires the green eval gate that makes
coinage auto legitimately, and opens the D30 “Re-read against the matter” seam.
Placement — the one correction that carries the Story (agent2 F4)
The RFD's v1 flow put segmentation between the card and _finalize_registration — the one
window where a raised fault goes through _fail → registration_failed →
SQS redeliver, the exact opposite of D13. The panel's fix, implemented here: segmentation is a
fan-out sub-step in src/shared/context/fanout.py, immediately before
_kick_enrichment. The document is already registered when it runs; every fan-out
step swallows its fault into a log line by contract; and D8's ordering (embedding serial after sections)
is free because the enrich kick is the last step. test_forced_segmentation_failure_is_isolated_from_registration
proves it: a step that RAISES still enrich-kicks, and the failure path structurally cannot reach
registration_status (the test's session factory asserts if any DB session is even opened).
The pass, end to end
| Move | Where | What happens |
| 1 · Invoke |
src/shared/context/segmentation.py |
agent_invoke.v1 with mode="segment", mirroring the card invoke:
document_pages rows as AnchoredPage dicts, the identity card's gist +
doc type as priming (D2), the server-resolved section-kind vocabulary (D6), and the D23
counsel_note slot (content is a later ticket). D27 windowing is agent-side; this side
always sends every page. Identity in the payload is the BROKER's — the model output DTO
carries none. Reuses the approved AGENT_INVOKE_BUILD/CALL spans; a new stream fold
consumes the document.sections event, added to the functions
agent_event_stream.v1 mirror (drift still fails loudly). |
| 2 · Persist |
persist_document_segmentation |
Replaces the run's rows in place (the uq_document_sections_doc_run_order idempotency
contract; migration 0169 grants the app role firm-scoped DELETE on this ONE derived table —
deliberately amending 0168's posture, with the static guard updated to pin the new one).
Kind resolution is reuse-first: a valid kind_key binds directly; a
proposed_kind_label goes through the propose_section_kind broker door;
anything refused lands on the structural other kind — a refused label never
un-sections a boundary that byte-proved itself. derived/sections.json is a best-effort
render cache (Postgres is the boundary authority, D3). The broker's identity wins: an artifact
naming a different document/matter degrades, never persists. |
| 3 · Degrade (D13) |
_degrade / persist_fallback_section |
ANY failure: log + flat single-section fallback (is_fallback=true, whole-document
range, kind other, honest pinning segmentation-fallback-v1/none) + ONE
document.resegment.requested outbox ticket per extraction run (deterministic
idempotency key = the D31 runaway cap; the resegment pass itself never re-enqueues, so the
automatic loop is depth-1). A failed RE-segmentation never clobbers a prior good run's sections. |
The taxonomy doors (D6/D26, agent2 F5)
| Tool | Posture | Mechanics |
list_section_kinds |
read |
Firm live catalog + the current matter's provisional working set, resolved from the RLS context
(SS-08). Closed at read: Blake picks from it or proposes a label; free-text kinds never bind. |
propose_section_kind |
auto, green-gated |
The ADR-150 containment shape, exactly like create_matter_document_type: the coined
kind is matter-scoped, provisional, reversible. Routed by name to a dedicated executor
branch (like exhibit marking) because its artifact IS the catalog row and its idempotency is the
normalized-name dedupe — the (document_id, extraction_run_id) registration claim
would wrongly skip every coinage as a duplicate of the classify run. The write door enforces,
independently of any agent-side linting: instruction-shaped/garbage label rejection (SS-13),
normalized-name dedupe (merge, never fork), the per-document cap and the per-firm/day cap
(get_settings() values, Story-2271 substrate columns). firm_id/
matter_id/document_id in the args are never read — the spoof test
proves the row lands in the RLS firm and the broker's matter. GREEN_EVAL_GATES maps it
to the pramaan-agents segment_gate (Story-2272: D14 golden diff + SS-13 injection
invariants); withdrawing the entry makes assert_auto_requires_green_gate go red
(planted-negative test) and the tool degrades to suggest — sections then land on
other until the door reopens. |
The re-read seam (D30)
POST /app/matters/{matter}/documents/{doc}/re-read (202) writes a
document.resegment.requested ticket into the existing ingest outbox — a FOURTH event
type (migration 0169 widens the CHECK, mirroring 0141's render precedent). The registration relay drains
it to the SAME registrar queue; the consumer routes it by event type to
process_resegment: a segmentation-only pass against the document's CURRENT
extraction_run_id (the fresh-pairing contract), primed off the STORED card, that never
touches registration_status. This is also how a needs_review/held document
gets its sections (agent2 F4b): the automatic fan-out only runs at a clean registered
terminal, and section consumers (assertions/discovery) are B3 scope — until then sections are
display-only by construction (D2). Counsel may re-read at will (per-click idempotency key; D31 records
cost, never optimizes against it). No immediate SQS dispatch from the API Lambda — the ≤60s
relay sweeper delivers, and no new IAM grant exists anywhere in this Story (R2: the consumer already
holds every capability the segment invoke needs).
What the tests prove
| AC | Test |
| Registration terminal unaffected by forced segmentation failure |
tests/unit/test_context_fanout.py::test_forced_segmentation_failure_is_isolated_from_registration
(+ the step's own never-raises tests in test_context_segmentation.py) |
| Flat fallback + re-seg queue entry; card=None degrade |
test_fallback_section_and_reseg_ticket (idempotent, one flat row);
test_card_none_still_segments_when_the_brain_seams_are_set +
test_no_brain_seams_skip_without_touching_the_db (the two F4c postures) |
| Identity server-derived; spoof ignored |
tests/integration/test_section_kind_coinage.py::test_spoofed_identity_args_are_ignored |
| Caps + dedupe + label-rejection (200-heading bundle) |
test_200_heading_bundle_is_capped_and_nothing_is_lost (cap coins, 200/200 sections
persist), test_duplicate_labels_merge_and_instruction_labels_are_refused,
test_coin_service_day_cap |
| GREEN_EVAL_GATES wired; auto only with green gate |
test_section_kind_tools_ship_the_d26_posture +
test_ungated_auto_is_rejected_by_the_guard (the red run) |
| Re-read re-runs segmentation with fresh extraction_run pairing |
test_reread_queues_a_resegment_ticket_and_consumer_pairs_fresh_run (end-to-end with a
fake Blake stream; asserts registration_status untouched) |
| OpenAPI snapshots | openapi/app-api.json regenerated and committed |
Out of scope, on purpose: collation verification/child minting (B2d), chunks/graph/insight
consumers of sections (B3), any UI, and the app repo's re-read proxy (a later ticket). The D23 counsel-note
slot ships empty; its content arrives with the note ticket.