Story 2072: Context Engine CE-14 — broker pre-fetch

CE-13 (Story-2071) built the assembler: one context_bundle.v1 (cards → chronology → evidence → assertions → contradictions), budgeted, hashed, cached in context.bundles, rebuilt by the event-debounced worker. CE-14 puts that bundle on the wire: the broker pre-fetches the pinned bundle at launch and stamps it onto agent_invoke.v1, beside the Memory Pack, in the ONE pre-fetch assembly (RFD docs/notes/rfd-context-engine.md §5.4/§5.5; the shared seam of docs/notes/rfd-memory-engine.md §12.2 R-6).

WHY — a brain can't read five stores mid-run

A brain's only egress is the inference provider (pramaan-docs ADR-110); it cannot query cards, retrieval, the Neptune graph, and assertions while it reasons. So the broker must hand it one bounded, versioned pack at launch. And the command panel spawns four roles far-side (proposer×2, critic, synthesizer). If each role fetched its own bundle they could see different context — the synthesizer would judge a draft built against a bundle it never saw, and "what did the model read" forks. CE-14 reads ONE bundle at launch and pins it: every role reads exactly this version_hash.

What changed

SurfaceChange
src/shared/agent_contracts/invoke.py New additive optional field AgentInvoke.context_bundle: ContextBundle | None, mirroring memory_pack. A missing bundle is valid (fail-open); the brain behaves as today. context_contracts is sqlalchemy-free (CI-checked), so importing ContextBundle keeps the envelope importable by the brains with no DB dependency.
src/shared/context/bundle_prefetch.py (new) prefetch_context_bundle(session, matter_id, kind) — a cache READ, never a build. Reads the pinned context.bundles row for the run's (profile, budget), chosen by turn kind (§5.5): mira_command → strategy×L (proposers get L breadth), dock_chat → chat×S (converse, frame primary). A miss returns None. Bundles are (re)built by the debounced worker (§9); building inline would blow the Lambda round-trip budget and DR-1.
src/clerk_worker/runner.py — _stamp_prefetch_packs() The one assembly point (R-6 / CE-14 seam). ME-7 built this function to stamp command["memory_pack"]; CE-14 adds command["context_bundle"] right beside it — both additive optional fields on the same fail-open pre-fetch, neither reworks the other. Scoped to a matter (a bundle needs a matter_id); a form-only or unscoped launch stamps no bundle.
src/clerk_worker/runner.py — _invoke_brain() Rides the stamped bundle onto the envelope: if command.get("context_bundle") is not None: envelope_dict["context_bundle"] = command["context_bundle"] — identical shape to the memory_pack ride.

The prompt sandwich — order preserved, frame OUTSIDE the bundle (§5.4)

system + role charter
  → current_frame @ frame_version          # frame lives OUTSIDE the bundle
  → memory block   (memory_pack.v1)         # ME-6/ME-7
  → bundle @ version_hash  (context_bundle.v1)   # CE-14 — this story
  → per-turn suffix  (injection nonces stay suffix-only)

The frame is carried once, outside the bundle and outside its hash (collision #6), so the cache key is (prompt_version, frame_version, version_hash). The base bundle is delimited with a version_hash-stable delimiter; per-run injection nonces stay in the per-turn suffix. The far-side rendering of this sandwich (per-role budgets: critic S, synthesizer verbatim) is the agents rail's CE-15/CE-16 — CE-14 pins the one base bundle they share.

One assembly point, two packs (§12.2 R-6)

process_command
  └─ (remember? → short-circuit, no brain)          # ME-7
  └─ _inject_granted_tool_catalog(command, deps)     # dock_chat tool catalog
  └─ _stamp_prefetch_packs(command, deps)            # ← THE seam
       ├─ command["memory_pack"]    = assemble_memory_pack(...)     # ME-7
       └─ command["context_bundle"] = prefetch_context_bundle(...)  # CE-14 (this story)
  └─ _invoke_brain → envelope_dict["memory_pack"]    = command["memory_pack"]
                     envelope_dict["context_bundle"] = command["context_bundle"]

"Done" = the empty first-run, through the real cache + DB

A seeded fixture hides the case that breaks first. The integration test (tests/integration/test_bundle_prefetch.py) drives prefetch_context_bundle against a real Postgres as pramaan_app:

The kind→axes decision is also locked as a pure unit test (tests/unit/context/test_bundle_prefetch.py): an unmapped kind (intake) pins nothing WITHOUT touching the session — a stray query on intake is a hard failure.

Scope boundary — what CE-14 does NOT build

Per-role prompt-sandwich rendering (proposer/critic/synthesizer budgets, the delimiters) is the agents rail (CE-15/CE-16) — CE-14 pins the shared base bundle. Persisting a context_provenance row needs its own store (a migration this no-migration story does not carry); the honesty record rides the consumer's turn.completed payload (§10), emitted far-side. Building on a cache miss is deliberately NOT done here — that is the debounced rebuild worker's job (§9); the broker only reads, keeping the invoke path DR-1-bounded.

Verify

uv run ruff check
uv run pytest tests/unit/context/test_bundle_prefetch.py \
              tests/integration/test_bundle_prefetch.py \
              tests/unit/agent_contracts        # envelope additive-diff
make openapi   # app/admin snapshots unchanged (agent_invoke.v1 is not an HTTP DTO)

Recall: CE-14 pre-fetches the pinned context_bundle.v1 and stamps it onto agent_invoke.v1 beside the Memory Pack in the one fail-open broker assembly, so every panel role reads exactly one version_hash — the bundle rendered after the memory block and after the frame, which stays outside the bundle.