ADR 0018 — Parent-child store-level expansion
Persist small-to-big context, wire the Chunker seam through ingestion, and expand only after RESTRICTED filtering while keeping child citations honest.
Status: accepted
Date: 2026-07-14
Immutable record. Exempt from drift tracking (no
covers). Supersede, don't edit.
This completes the follow-up deferred by ADR 0016: moving parent-child behavior to retrieval-time store expansion. It is constrained by engine ADR 0005 (isolate new capability from the byte-identical default) and engine ADR 0010 (deterministic isolation independent of model output).
Context
ADR 0016 added ParentChildChunker. At chunk time a child carried window_text (the parent
section's full text) and parent_locator (the section's real paragraph span). But the Chunker
seam was not wired into narrative ingestion and those fields were not persisted. End-to-end
small-to-big—hit a fine child, provide parent context for generation—therefore never ran on the
real stored path.
This record closes the gap: persist hierarchy/window fields, wire the chunker into both ingestion paths, and expand at the retrieval exit while preserving byte-identical default behavior, anti-fabrication, provenance, and RESTRICTED double-exit isolation.
Decision
Three additive changes, all opt-in; the default path remains byte-identical.
Persistence: additive columns, old databases readable
narrative_chunk gains parent_id, heading, window_text, and parent_locator, each
TEXT NOT NULL DEFAULT ''. ChunkStore.init_schema performs an additive migration:
PRAGMA table_info, followed by a per-column ALTER TABLE ADD COLUMN. Existing rows receive empty
defaults and stay readable; there is no destructive rebuild. StoredChunk mirrors the four fields
with empty defaults.
A schema-version rebuild was rejected: additive columns are simpler and keep old libraries live.
Ingestion wires the Chunker seam
Both writers accept chunker: Chunker | None, where default None delegates to the built-in
chunk_document and remains byte-identical:
ingest_narrative(..., chunker=)for the batch pipeline.NarrativeIndex(chunker=)for retrieval-side ingest and embedding.
ServiceConfig.chunker selects through make_chunker, defaulting to "none", and the narrative
worker payload carries the selection. With parent_child/small_to_big, children arrive with
window_text/parent_locator and the store persists them.
Retrieval expands after the A-line isolation exit
In narrative_link._to_snippet, a non-empty window_text becomes a separate prompt_text key. The
agent's _snippet_text already prefers that key as generation context. text, source_locator,
and chunk_id remain those of the hit child. parent_locator is an additional provenance
backreference to the parent span.
Expansion changes generation context only. It never becomes a hit, changes rank, or adds a result.
Default chunkers produce empty values, so no keys are added and the snippet mapping remains
byte-identical. This reuses the prompt_text layering established for post-retrieval compression.
Pinned RESTRICTED-window decision
window_text rides the child's existing RESTRICTED double-exit; when a child is RESTRICTED, the
entire snippet—including its parent window—is rejected.
_to_snippet runs only after the link/ exit removes sensitivity == RESTRICTED chunks. A
restricted child never reaches window expansion, and its window_text can never become
prompt_text. Of two candidate semantics—remove only the window or reject the entire snippet—we
choose whole-snippet rejection. Parent context is exposed only through a child that passed both
exits.
Under the current document-level sensitivity model, child and parent share a sensitivity, so an
allowed child with a restricted parent section cannot arise from ingestion. The mechanism is still
pinned defensively. test_parent_child_isolation.py supplies a restricted chunk containing a
window and proves that the entire snippet is rejected with no leakage.
Alternatives considered (rejected)
- Replace snippet
textwithwindow_text: expanded context would masquerade as hit evidence and break provenance honesty. Child text/locator remain citable; the window is generation-only. - Strip the window but keep a restricted child snippet: partial handling is more fragile than rejecting the entire snippet and conflicts with the existing exit semantics.
- Expand before RESTRICTED filtering: this opens a direct leak path. Expansion occurs strictly in
_to_snippet, downstream of the strip. - Add a schema version and rebuild: additive
ALTER TABLEkeeps old databases readable without migration ceremony.
Consequences
- Small-to-big works end to end: a precise child hit supplies a parent section for generation while the citation remains pinned to the child's true locator and the parent locator remains a backreference.
- The offline default is byte-identical: default chunking leaves fields empty, adds no snippet keys, and does not alter scoring or provenance. BM25/vector metadata recording never reads the new fields.
SentenceWindowChunkeralso gains persistence through the same path.- Changed artifacts include four
ChunkStore/StoredChunkfields and migration, the twochunker=ingestion seams,ServiceConfig.chunkerand worker threading,narrative_link._to_snippetwindow-to-prompt_text, andtests/conformance/test_parent_child_isolation.pyplus persistence/migration tests.