Story 2282: B5f — the section layer travels

WHY. B1–B3 built the semantic section layer and taught every internal consumer to read it (schema #645, Blake segmentation #647, collation #648, consumers + read route #649), and the reading surface shipped (pramaan-app#568). Counsel can now see a document's sections. But when counsel talks to an agent about a document — “why is this ground weak?” — the agent still received a matter-wide context pack with no idea which paragraph the question was about. This Story makes sections travel with the ask: the context envelope an agent receives when counsel selects part of a document.

The one decision that shapes everything: D16 — a PROFILE, not an envelope

The obvious implementation is a new DocumentContext envelope with its own assembler. The RFD's CE-13 amendment (D16) explicitly forbids it, and the reason is the one that already bit us once: if assembly is ad-hoc per consumer, the synthesizer reads a different context than the proposer it is judging, the cache key drifts, and “what did the model actually read” becomes unanswerable. So document_selection is a new recipe over the existing context_bundle.v1 — same envelope, same budget engine, same version_hash. The only genuinely new thing is what it fetches.

The seam, made structural. Previously assemble_bundle emitted all five sections always, and a profile's recipe only set their sizes. Now the recipe decides membership: a section kind a profile does not weight is not part of that bundle, not a zero-budget empty. That one change is what lets a new profile compose a different bundle without forking the assembler. The five matter-wide profiles weight all five original kinds, so their bundles — and their version_hashes — are byte-identical to before (proved by test_adding_the_profile_does_not_move_the_matter_wide_profiles).

What the profile serves

PartWhereWhat it is
1 · Matter frame — (outside the bundle) The frame lives outside the bundle AND outside the hash (CE-13 collision #6): the prompt prefix is system + charter → frame@frame_version → bundle@version_hash, assembled by the consumer. So “truncation never drops the matter frame” holds by construction — the budget cut cannot reach a thing the bundle does not contain. Nothing to implement; worth stating, because the alternative (copying the frame into the bundle) would have been a silent contract fork.
2 · Document identity card DocumentIdentityCard
doc_identity section (recipe 0.10)
Gist, kind, status — what am I reading? Sourced from documents.documents (always present) LEFT JOINed to the enrichment card (context.document_cards), so a document whose enrichment has not run yet still gets an honest identity with a null gist, never a missing section and never a fabricated summary. Pinned through truncation: section summaries with no idea what they are sections of is not a cheaper bundle, it is a useless one.
3 · Section summaries SectionSummaryItem
section_summaries section (recipe 0.90)
The ordered (order, kind, heading_path, summary) rows from documents.document_sections (D15), at the document's current extraction_run_id only — invariant 4, a stale run's boundaries are never served. Each summary is clipped to a config budget and says so (summary_truncated).
4 · Focus axis DocumentFocus
echoed on ContextBundle.focus_axis
Where the ask points: section id(s) + the anchored (page, block) range + counsel's verbatim selection quote. Counsel selects two ways — by clicking a section, and by dragging over text — so the range resolves by containment against the same lexicographic ordering the boundaries were drawn in (a selection spanning two sections marks both). A selection that resolves to no section still carries the axis: the agent knows what was pointed at even when nothing contains it.

Fail-closed, at the query layer (SS-18)

The order is the contract: classify, then fetch. The classification gate is the first query; a privileged document raises before a single section summary has been read into memory.

CaseVerdictWhy this and not something softer
privilege_flag PrivilegedContextRefusedError (403) The refusal is total — not a bundle with the privileged parts filtered out. “We removed the privileged bits” is a claim nobody can audit after the fact; “nothing was assembled” is checkable.
home = 'strategy' StrategyContextExcludedError (403) Strategy is counsel's own analysis and is structurally excluded from world-fact machinery (Story-2119 homes doctrine). D15 extends that to the summaries: a summary of a strategy section is strategy. Display remains the one allowlisted strategy consumer — the viewer renders these sections happily; the agent-bound leg does not.
Another firm's document PramaanNotFoundError (404) firm_id is a predicate on every statement, so another firm's document is invisible, not forbidden. A 403 would itself leak the fact that the document exists.
Enforced twice, and that is not redundancy. The gate raises the typed refusal (so the caller gets an honest answer instead of an empty bundle), and the section/identity reads carry the same NOT privilege_flag AND home = 'record' AND availability = 'live' predicate inline — so the exclusion holds even for a future caller that reaches the read helpers without the gate. Exclusion is a property of the statement, not of the caller's memory. test_exclusions_are_enforced_at_the_query_layer_not_by_post_filtering asserts the statement returns zero rows for an excluded document — and asserts a non-zero baseline first, so it cannot pass on a query that fetches nothing for anyone.

The trap: why this profile is deliberately NOT cached

The bug that was one line away. context.bundles is keyed (firm_id, matter_id, profile, budget, coalesce(role_hint, '')) — with no document column. build_bundle ends by upserting into it. Had document_selection reused that path, document A's bundle would have overwritten document B's on the same matter, and the next read would have served one document's sections as the other's context — a cross-document context leak inside a firm, invisible in every test that only ever used one document.

So this profile assembles fresh and never touches that cache. It can afford to: D16's falsifiable latency contract is “no embedding call, no graph traversal, no rerank at click; ≤2 bounded index reads”, and that is exactly what it is — the classification gate + identity card is one read, the section summaries are the second. The debounced rebuild consumer walks the DISTINCT axes already present in context.bundles, so a profile that never writes there is never rebuilt there either. Self-consistent; no special case needed.

Budgets and deterministic truncation

Both budgets are config, not literals: the whole-bundle ceiling is the existing named S/M/L tier, and the per-section summary budget is CONTEXT_BUNDLE_SECTION_SUMMARY_MAX_CHARS (default 600). The summary budget does two jobs — it stops one pathological summary eating the section's whole allocation, and it bounds a single item, which is what makes pinning the focus safe.

One budget engine, two walk orders. _truncate_to_budget walks items in a keep-priority order and stops at the first item that does not fit; kept items are always served in document order. With no priority supplied it walks natural order — exactly the tail-drop the matter-wide profiles always had. The section-summaries section supplies a priority of nearest-to-focus first, so “stop at the first that does not fit” is “drop farthest-from-focus first”.

The focus is PINNED and never dropped. A document-selection bundle that budget-dropped the paragraph counsel pointed at would answer confidently about the wrong part of the document — worse than a bundle that overruns its target, which at least says so. Truncation is recorded in bundle metadata (BundleSection.truncated per section, summary_truncated per item).
The hash trap. Two selections in the SAME document can serve byte-identical section items while pointing at different paragraphs — the same section with a different quote, or two bare span selections that resolve to no section at all. Without the focus as a version_hash input those bundles hash equal, and any consumer keyed on the hash would serve one question's context in answer to another. So document_focus joins the hash canonical — added only when present, so the matter-wide profiles' hashes do not move and no layout_version bump (and no fleet-wide cache invalidation) is needed.

“No green counts until it has been red”

Per AGENTS.md, a detector is evidence only after it has been observed failing. Every load-bearing guard in this Story was planted-negative tested — the guard was removed, the test was observed RED, the guard was restored:

#Guard removedDetector that went red
N1document_focus dropped from the hash canonical test_two_selections_with_identical_sections_do_not_collide_onto_one_hash + test_a_bare_span_selection_does_not_collide_either
N2pinned ignored (focus droppable) test_the_focus_section_survives_a_budget_that_fits_nothing
N3recipe-gating removed (base profiles grow empty D16 sections) test_adding_the_profile_does_not_move_the_matter_wide_profiles + 4 more
N4truncation walks document order, ignoring focus priority test_truncation_drops_farthest_from_focus_first
N5the privilege_flag gate test_privileged_document_is_refused_with_zero_partial_context
N6the exclusion predicates in the section-summaries SQL test_exclusions_are_enforced_at_the_query_layer_not_by_post_filtering

Two of these tests were vacuous when first written and the planted-negative pass is what caught them — which is the entire argument for the practice:

Observability

Assembly reuses the registry-listed functions.context.bundle_build manual span (ADR-124) — this is a bundle build. No new span name and no new attribute keys: a new profile of an already-instrumented operation does not widen the closed ADR-124 registry.

Out of scope (unchanged): the CCB chassis UI that calls this (B5a), Blake prompt changes, and new tables — this Story ships no migration (alembic check: “No new upgrade operations detected”).

Files

FileChange
src/shared/context_contracts/document_selection.py new — DocumentIdentityCard, SectionSummaryItem, DocumentFocus
src/shared/context/document_selection.py new — the classification gate + 2 bounded reads + focus resolution; calls the shared assemble_bundle
src/shared/context/bundle_builder.py recipe now composes the profile; _SectionInput→SectionInput (a shared seam, so public); keep-priority + pinning in the budget walk; focus in the hash + echoed on the bundle
src/shared/context_contracts/{bundle,enums}.py document_selection profile; doc_identity + section_summaries kinds; item union; focus_axis
src/shared/settings.py the recipe + CONTEXT_BUNDLE_SECTION_SUMMARY_MAX_CHARS + CONTEXT_BUNDLE_SECTION_FETCH_CAP
src/shared/errors/exceptions.py the two typed refusals (docstring + remediation, per the error-contract gate)
src/app_api/router_context.py POST /app/matters/{matter_id}/context/bundles/document-selection
openapi/app-api.json regenerated — exactly one path added, zero pre-existing paths or schemas changed