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.
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).
| Part | Where | What 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 | DocumentIdentityCarddoc_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 | SectionSummaryItemsection_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 | DocumentFocusechoed 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. |
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.
| Case | Verdict | Why 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. |
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.
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.
context.bundles, so a profile
that never writes there is never rebuilt there either. Self-consistent; no special case needed.
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”.
BundleSection.truncated per section, summary_truncated per item).
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.
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 removed | Detector that went red |
|---|---|---|
| N1 | document_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 |
| N2 | pinned ignored (focus droppable) |
test_the_focus_section_survives_a_budget_that_fits_nothing |
| N3 | recipe-gating removed (base profiles grow empty D16 sections) | test_adding_the_profile_does_not_move_the_matter_wide_profiles + 4 more |
| N4 | truncation walks document order, ignoring focus priority | test_truncation_drops_farthest_from_focus_first |
| N5 | the privilege_flag gate |
test_privileged_document_is_refused_with_zero_partial_context |
| N6 | the 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:
section_ids.
That flips is_focus on the items themselves, so the hash moved anyway and the test passed
with the guard deleted. Rewritten to hold the served items byte-identical (same section, different
quote) — and it now asserts that precondition explicitly, so it cannot silently rot back.
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.
alembic check: “No new upgrade operations detected”).
| File | Change |
|---|---|
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 |