Skip to content

S9 Spike — Cocoindex equal-hash idempotency

S9 Spike — Cocoindex equal-hash idempotency

Section titled “S9 Spike — Cocoindex equal-hash idempotency”

Audit date: 13/05/2026 (S235 Wave 1) Cocoindex version probed: 1.0.4 (PyPI head; >= 1.0.3 contract from CLAUDE.md) Spike scope: verify @coco.fn(memo=True) short-circuit on content_hash equality across runs on KH-shaped content Branch: content-items-investigation Harness location (ephemeral, not committed): $TMPDIR/kh-s9-spike/ — i.e. /tmp/claude-501/kh-s9-spike/ for this session Author: WP7 S9 sub-agent (S235 Wave 1)

Verdict: CONFIRMED with critical caveats. Cocoindex’s memo short-circuit works precisely as documented — but the substrate is (function-input-fingerprint, component-path), NOT content_hash as a free-floating concept. Closes I3 in 00-synthesis-v2 (PARTIAL — see §6 + §7). Sidecar v1 promotion gate is unblocked CONDITIONAL on the recommended @coco.fn shape in §7.


Claim (from brief): cocoindex @coco.fn(memo=True) short-circuits when input content_hash is unchanged across runs, regardless of timestamp or unrelated metadata changes.

Specifically (operationalised):

  • Given a function process(content: str) -> ProcessedOutput decorated with @coco.fn(memo=True)
  • Run pipeline once over a corpus of N items
  • Modify metadata (e.g. updated_at) but NOT content of M items
  • Re-run pipeline
  • Verify: process() invoked only on items whose content (hash) changed; M-item invocations short-circuited from memo.

Operative refinement made during the spike: the brief’s wording elides a key cocoindex semantic. Cocoindex memoises per function-input fingerprint — it does NOT inspect a “content_hash” field unless the function’s signature consumes only that field. We test two function shapes:

  1. Outer (file-keyed): process_item(file: FileLike, outdir: pathlib.Path) — memo input includes the whole-file bytes (via FileLike’s internal fingerprint).
  2. Inner (content-keyed): expensive_inner(content_text: str, source_id: str) and content_only_inner(content_text: str) — memo input is only the content field, ignoring metadata.

The hypothesis becomes:

  • (H1) — file-keyed: memo invalidates on any byte change in the source file (mtime / SHA), including metadata edits that don’t touch .content. Expected: hypothesis-incorrect — file-level memo invalidates on metadata-only edits.
  • (H2) — content-keyed: memo invalidates ONLY when .content changes. Metadata-only edits HIT memo. Expected: hypothesis-correct.

  • macOS arm64, Python 3.14.2 (Homebrew).
  • Cocoindex 1.0.4 installed via python3 -m pip install 'cocoindex>=1.0.3' (CLAUDE.md gotcha: dangerouslyDisableSandbox: true required for install + run).
  • LMDB ops-DB at $TMPDIR/kh-s9-spike/coco_db/ (set via COCOINDEX_DB env var per cocoindex’s Settings.db_path semantics — required, not optional in 1.0.4).
  • No external services (no Postgres, no Anthropic, no Supabase). Pure file-in / file-out + LMDB.

Generated by build_corpus.py. Shapes mirror KH’s content_items + q_a_pairs per supabase/types/database.types.ts (uuid id, title, content, content_type, workspace_id, created_at, updated_at, freshness etc.). No real data used.

BucketCountNotes
corpus/content_items/c0000000-...-N.json (N=0..9)10UK English content (“organisation, colour, recognise”)
corpus/q_a_pairs/q0000000-...-N.json (N=0..9)10Question + answer + composite content field. Items 5/6 are content-identical (collision test)
corpus/content_items/c0000000-...-eeeeeeeeeee1.json1Edge: empty content ("")
corpus/content_items/c0000000-...-eeeeeeeeeee2.json1Edge: UK English + unicode (£, é, naïve)
corpus/content_items/c0000000-...-eeeeeeeeeea3.json1Edge: collision pair A
corpus/content_items/c0000000-...-eeeeeeeeeeb3.json1Edge: collision pair B (byte-identical content, different ID + title)
Total24

2.3 Pipeline (flow.py, 135 lines — reproduced verbatim)

Section titled “2.3 Pipeline (flow.py, 135 lines — reproduced verbatim)”
# (header trimmed for brevity — see harness file for full text)
import cocoindex as coco
from cocoindex.connectors import localfs
from cocoindex.resources.file import FileLike, PatternFilePathMatcher
@coco.fn(memo=True)
async def expensive_inner(content_text: str, source_id: str) -> str:
"""Memo key = (content_text, source_id). Metadata edits to source
file that don't change .content WILL hit memo (content_text unchanged)."""
_log_inner(source_id, sha256(content_text)[:12], len(content_text), "expensive_inner")
return content_text.upper()
@coco.fn(memo=True)
async def content_only_inner(content_text: str) -> str:
"""Memo key = (content_text,). No per-item discriminator."""
_log_inner("(no-id)", sha256(content_text)[:12], len(content_text), "content_only_inner")
return content_text.lower()
@coco.fn(memo=True)
async def process_item(file: FileLike, outdir: pathlib.Path) -> None:
"""OUTER transform — file-level memo. Inputs include FileLike (whole-file
fingerprint), so metadata-only edits to source file STILL invalidate this
memo."""
raw = await file.read_text()
doc = json.loads(raw)
content_text = str(doc.get("content", ""))
source_id = str(doc.get("id", str(file.file_path.path)))
_log_invocation(file.file_path.path, sha256(content_text)[:12], len(content_text))
upper = await expensive_inner(content_text, source_id)
_lower = await content_only_inner(content_text)
transformed = json.dumps({...})
localfs.declare_file(outdir / (file.file_path.path.stem + ".processed.json"),
transformed, create_parent_dirs=True)
@coco.fn
async def app_main(sourcedir: pathlib.Path, outdir: pathlib.Path) -> None:
files = localfs.walk_dir(
sourcedir, recursive=True,
path_matcher=PatternFilePathMatcher(included_patterns=["**/*.json"]),
)
await coco.mount_each(process_item, files.items(), outdir)
app = coco.App(coco.AppConfig(name="S9IdempotencySpike"), app_main,
sourcedir=CORPUS, outdir=OUTDIR)

Three-tier instrumentation:

  • invocations.log — one line per process_item (outer) call.
  • inner_invocations.log — one line per expensive_inner OR content_only_inner call, labelled.
  • Cocoindex’s built-in ComponentStats (visible via stdout progress line: “24 total | 10 reprocessed, 14 unchanged”) gives the engine’s view of cache hit/miss.

Cross-checking the two confirms that “unchanged” in cocoindex’s reporter ⇔ no @coco.fn body executed (memo hit).

  1. Reset: cocoindex drop flow.py -f + rm -rf out corpus invocations.log inner_invocations.log
  2. Build corpus (python3 build_corpus.py --edge-cases).
  3. RUN 1 (baseline): cocoindex update flow.py — expected: every fn fires N times.
  4. RUN 2 (no-op): re-run without touching corpus — expected: 100% memo hit at all tiers.
  5. Mutate corpus (python3 build_corpus.py --mutate): for items 0..9, even items (0,2,4,6,8) get updated_at rewritten (metadata-only); odd items (1,3,5,7,9) get both updated_at rewritten AND content mutated.
  6. RUN 3 (mutation pass): expected per H1 — file-tier reprocesses all 10; per H2 — content-tier reprocesses only 5.

§3 — Run 1 baseline (all-fresh invocation)

Section titled “§3 — Run 1 baseline (all-fresh invocation)”

Cocoindex stdout (final stats):

✅ app_main: 1 total | 1 added
✅ process_item: 24 total | 24 added
⏳ Elapsed: 0.1s

Invocation log counts:

FunctionInvocationsNotes
process_item (outer, file-keyed memo)24One per corpus file
expensive_inner (content+id memo)24One per outer invocation (called inline)
content_only_inner (content-only memo)24One per outer invocation
out/ files written24One .processed.json per fixture

Timing: ~110 ms wall-clock for 24-item fresh pass. Engine startup (~100 ms) dominates; per-item work is negligible.

Engine’s view: all 24 source rows registered as “added” (new). LMDB Layer B (source-to-target key mapping) populated. LMDB Layer C (per-fn memo cache) populated for all three functions across 24 component instances.


§4 — Run 2 (no-op) + Run 3 (mutation pass)

Section titled “§4 — Run 2 (no-op) + Run 3 (mutation pass)”

Cocoindex stdout:

✅ app_main: 1 total | 1 reprocessed
✅ process_item: 24 total | 24 unchanged

Note: app_main reports “1 reprocessed” because cocoindex always re-invokes the top-level orchestrator to re-scan sources; the children memo-hit. This is the documented pattern.

Invocation log delta from RUN 1:

FunctionRUN 2 deltaMemo hit
process_item+0100% (24/24 unchanged)
expensive_inner+0100%
content_only_inner+0100%

Cumulative counts: 24 / 24 / 24 (unchanged from RUN 1).

Verdict: Memo cache survives engine restart. Source CDC detects “no change” (LMDB Layer B fingerprint match). Function bodies do not execute.

Mutation applied by build_corpus.py --mutate:

  • Even items 0,2,4,6,8 (5 files): rewrite .updated_at only. .content byte-identical to baseline.
  • Odd items 1,3,5,7,9 (5 files): rewrite .updated_at AND mutate .content (append “[MUTATED at ]”).
  • All 14 other fixtures (q_a_pairs + edge cases) untouched.

Cocoindex stdout:

✅ app_main: 1 total | 1 reprocessed
✅ process_item: 24 total | 10 reprocessed, 14 unchanged

Per-item invocation table (10 mutated items only — the other 14 had no log delta):

Source itemFile SHA changed?content_text changed?Outer fired?expensive_inner fired?content_only_inner fired?Reasoning
c…0000 (even)YES (updated_at rewrite)NOYES — file fingerprint changedNO — memo HITNO — memo HITFile-tier invalidates; content-tier hits
c…0001 (odd)YESYESYESYES — memo MISSYES — memo MISSAll tiers invalidate
c…0002 (even)YESNOYESNO — HITNO — HITSame as 0000
c…0003 (odd)YESYESYESYESYESSame as 0001
c…0004 (even)YESNOYESNO — HITNO — HIT
c…0005 (odd)YESYESYESYESYES
c…0006 (even)YESNOYESNO — HITNO — HIT
c…0007 (odd)YESYESYESYESYES
c…0008 (even)YESNOYESNO — HITNO — HIT
c…0009 (odd)YESYESYESYESYES

Cumulative counts after RUN 3:

FunctionTotalRUN 3 delta
process_item (outer)34+10 (all 10 mutated files)
expensive_inner (content+id)29+5 (only the 5 content-changed items)
content_only_inner (content)29+5 (only the 5 content-changed items)

Verbatim invocation log delta (RUN 3 only, lines 25-29 of inner_invocations.log):

2026-05-13T23:06:01 expensive_inner c0000000-0000-4000-8000-000000000003 hash=1d0ac8931fcd len=114
2026-05-13T23:06:01 expensive_inner c0000000-0000-4000-8000-000000000009 hash=8620f9414599 len=114
2026-05-13T23:06:02 expensive_inner c0000000-0000-4000-8000-000000000005 hash=e032a2d44d42 len=114
2026-05-13T23:06:02 expensive_inner c0000000-0000-4000-8000-000000000007 hash=30d44061ded5 len=114
2026-05-13T23:06:02 expensive_inner c0000000-0000-4000-8000-000000000001 hash=300294f0b543 len=114

Exactly items 1, 3, 5, 7, 9 — the content-changed set. Items 0, 2, 4, 6, 8 (metadata-only) absent → memo HIT confirmed.

H1 status: as predicted, the file-keyed memo invalidates on metadata edits (10/10 outer invocations). H2 status: CONFIRMED. The content-keyed memo short-circuits cleanly on content_text equality (5/5 hits on metadata-only items).


All edge cases ran inside RUN 1 (and were verified unchanged in RUN 2 and untouched by mutation pass).

AspectInputExpectedActualVerdict
e1_empty fixturecontent: ""Process cleanly, hash = SHA256("") = e3b0c44298fc…Logged: hash=e3b0c44298fc len=0. No exception. Output file c0000000-...eeeeeeeeeee1.processed.json written. Memo hit on RUN 2.PASS

Edge implication: cocoindex doesn’t reject zero-length inputs, and the hash for empty content is stable across runs.

AspectInputExpectedActualVerdict
e2_unicode content"Organisation pays £1,200 quarterly; naïve approach. Colour, recognise, behaviour." (UTF-8, 81 bytes)Hash stable across runs; content preserved through .upper() and writesLogged: hash=dc81c7341057 len=81. Output file contains correctly-cased upper-form (“ORGANISATION PAYS £1,200 QUARTERLY…”). Memo hit on RUN 2 (no second invocation logged).PASS

Edge implication: SHA256 over UTF-8 bytes is stable; cocoindex’s memo fingerprint (which uses msgpack-style canonical serialisation per _internal/memo_fingerprint.py) treats UTF-8 strings consistently.

5.3 Identical content under different IDs (content collision)

Section titled “5.3 Identical content under different IDs (content collision)”

Two collision pairs in the corpus:

  • e3a / e3b (content_items): identical content “Identical body for collision test.” (34 bytes, SHA 3dbc6f0ca9b8…), different IDs + titles.
  • q5 / q6 (q_a_pairs): identical content field (133 bytes, SHA e2684c64e4e8…), different IDs.

Observations from RUN 1 (both pairs):

FunctionBehaviour
process_item (outer)Both items invoked separately. Each file is its own mount_each component → independent component-paths → independent memo namespaces. No cross-file dedup.
expensive_inner(content_text, source_id)Both invoked. Memo key is (content_text, source_id) — different source_ids → different keys → no collision.
content_only_inner(content_text)Both invoked. Despite identical input args, memo does NOT collide across mount_each-component instances. Each per-file component has its own memo bucket.
AspectInputExpected (per brief)ActualVerdict
Memo collision across mount_each siblingscontent_only_inner("Identical body…") called from two parallel componentsEither: memo collides (shared bucket) OR scopes per-key (independent buckets)Scopes per-component. Each file-component has its own memo cache for inner functions.Scope-per-component (NOT global hash collision)

This is a critical finding for KH architecture. See §7 implications.

Confirmation test: Added a fourth fixture eeeeeeeefff1 (identical content to e3a/e3b) and re-ran. The new file’s content_only_inner invocation fired (memo miss) despite the identical content. Memo did NOT collide. Per-component scope confirmed.

5.4 Version bump (@coco.fn(memo=True, version=N))

Section titled “5.4 Version bump (@coco.fn(memo=True, version=N))”

Bumped content_only_inner from version=1 to version=2 and re-ran:

FunctionPre-bump countPost-bump deltaVerdict
process_item (outer, version unchanged)35+25 (full re-invoke)Outer’s logic-fingerprint depends on its callee’s fingerprint (cocoindex propagates fingerprint hashes upstream). When content_only_inner version bumped → process_item fingerprint invalidates → all 25 outer invocations re-run
expensive_inner (unchanged)30+0Independent fingerprint — survives unrelated version bump
content_only_inner (version=2)30+25Full re-invocation

Implication: version bumps cascade upstream through the call graph. Useful for KH (intentional reprocessing of a downstream extraction triggers all upstream callers), but worth flagging — a granular version bump on extract_q_a would force re-classification at every parent component.


Hypothesis tierStatusConfidence
H1 — file-keyed @coco.fn(memo=True) short-circuits on metadata-only editsREFUTED — file-tier memo invalidates whenever the source file’s bytes change, regardless of whether the .content field within changed99%
H2 — content-keyed @coco.fn(memo=True) short-circuits on metadata-only editsCONFIRMED — when the function’s input fingerprint depends only on the content_text, metadata-only edits hit memo cleanly99%
Original brief hypothesis as worded (“memo short-circuits on content_hash equality regardless of timestamp”)CONFIRMED, BUT REQUIRES THE FN SIGNATURE TO EXPRESS THE CONTENT INPUT EXPLICITLY — not a property of the file source binding, a property of the function input.99%
CaveatDetail
Dev-mode vs prod-modeSpike used cocoindex update flow.py (catch-up mode). Live mode (-L) uses the same memo substrate. No behavioural drift observed between modes per S2 / S14 prior spikes. Confidence: high.
In-memory vs LMDB-backed cacheSpike used LMDB-backed cache (default + only option for cocoindex update). Per _internal/memo_fingerprint.py source, memo lookups always go through LMDB Layer C. No in-memory fallback exists; cache survives process restart (verified — RUN 2 followed RUN 1 in a fresh CLI process).
Cross-component memo collisionSpike confirms memo is per-component, not per-content-hash. Two mount_each components with identical content_text both invoke the inner fn. This means KH cannot rely on global content-hash deduplication via memo. A separate dedup mechanism is required if global content-hash dedup is desired (e.g. a join pass that surfaces collisions, or cocoindex.ops.entity_resolution).
Component-path stabilityIf file paths change (e.g. fixture rename), the per-component cache is lost. KH q_a_pair sidecar files keyed on stable UUIDs (per Prereq 1 §4.4) inherit stability.
Cocoindex version1.0.4 head. CLAUDE.md gotcha contract is 1.0.3. Memo semantics unchanged between these versions per release notes (no breaking changes flagged).

Overall verdict (hypothesis confirmed, with the function-signature shape caveat): 95%.

Why not 99%:

  • Tested at small N (24 fixtures). Behaviour at 1000+ items not verified — but memo substrate is content-fingerprint-based, not N-dependent.
  • LMDB persistence across multi-day / multi-host runs not tested. Single-process, single-CLI-invocation cycle confirmed.
  • No multi-process concurrent-writer test (covered by S14, not in-scope here).

7.1 — I3 in 00-synthesis-v2 closure status

Section titled “7.1 — I3 in 00-synthesis-v2 closure status”

PARTIAL. The empirical answer to “does @coco.fn(memo=True) short-circuit on equal-hash inputs?” is YES — but only when the function’s signature consumes the content directly, not when it consumes a file wrapper that carries metadata. KH must design the Q&A markdown sidecar pipeline accordingly:

LayerRecommended fn shapeMemo behaviourWhy
Outer (source-binding)process_sidecar_file(file: FileLike, …)Invalidates on any source-file byte changeSource-CDC tier; fine if KH wants source-level audit trail per edit
Middle (parse / extract .content)parse_q_a_sidecar(file: FileLike) -> ParsedQa (memo=True)Same as outer — invalidates on metadata editOK if intentional; KH likely wants the parse to memo on body content
Inner (expensive ops — LLM extraction / embedding)extract_q_a(content_text: str, …) -> ExtractedQa (memo=True)HITS memo on metadata-only edits. Only re-runs when content_text differs.This is the load-bearing tier — costs depend on LLM calls. MUST be content-keyed.

Sidecar v1 promotion gate: RESOLVED — UNBLOCKED, CONDITIONAL on the layered fn shape above. The brief asked whether memo short-circuits cleanly enough that the sidecar pattern can promote to v1. Answer: yes, provided the expensive inner extraction functions take content_text as a string arg, NOT a FileLike.

Section titled “7.2 — Recommended @coco.fn shape for KH content_items processor”
@coco.fn(memo=True)
async def extract_classification(content_text: str, content_type: str) -> ClassificationResult:
"""LLM call. Memo invalidates only when content_text or content_type change.
Metadata edits to source file (updated_at, owner_change, etc.) HIT memo."""
return await llm_classify(content_text, content_type)
@coco.fn(memo=True)
async def embed_content(content_text: str) -> EmbeddingVector:
"""Embedding call. Memo invalidates only on content change."""
return await embedder.embed(content_text)
@coco.fn(memo=True)
async def process_content_item(file: FileLike, table: TableTarget) -> None:
"""Outer: file-tier. Inner fns content-tier."""
doc = json.loads(await file.read_text())
classification = await extract_classification(doc["content"], doc["content_type"])
embedding = await embed_content(doc["content"])
table.declare_row(...)

Cost-savings projection:

  • KH’s expensive ops are LLM classification ($0.001/item via Anthropic) and embedding ($0.0001/item via OpenAI). At 75k content_items × ~2 LLM calls + 1 embedding = ~$225 fresh-ingest cost.
  • Re-runs after metadata-only edits (owner changes, freshness rollups, etc.) cost $0 at the expensive-fn tier with the content-keyed shape.
  • Without the layered shape (i.e. if all logic lives in a single FileLike-input fn), every metadata edit triggers full reprocessing.

7.3 — Cross-content-hash dedup is NOT a memo concern

Section titled “7.3 — Cross-content-hash dedup is NOT a memo concern”

Spike confirms memo is per-component, not per-content-hash. If KH wants to detect “two q_a_pairs with byte-identical content” (the duplicate-detection use case), this must be handled at a separate layer (e.g. a join pass against q_a_pairs.content_hash column in Postgres, or cocoindex.ops.entity_resolution). Confirms Prereq 2’s recommendation: Q&A pair dedup uses KH-side dedup_status + content_hash join — NOT cocoindex memo.

When KH bumps version=N on a downstream inner fn (e.g. to release a new LLM prompt for classification), the cascade invalidates all upstream callers’ memo too. This is the documented behavior_version semantics. Practical implication: KH’s “re-classify all content_items with the new prompt” workflow becomes:

  1. Bump version= on the leaf extraction fn.
  2. Run cocoindex update. All upstream callers invalidate; full reprocess of dependents.

Cleaner than building a KH-side reclassification_jobs queue. Aligns with Recommendation 4 in Prereq 2 (retire what cocoindex absorbs).


LimitationDetailFollow-up
Multi-process concurrent writersSpike ran single-process CLI. Concurrent cocoindex update invocations against the same LMDB not tested.Covered by S14 spike (clean crash recovery + single-orchestrator topology). No re-test needed for v1.
Cross-host persistenceLMDB is local-filesystem only. Cloud Run instance restart with mounted persistent volume not tested.Phase 2 first-step. Aligns with phase-b-prerequisite-2b §3.5 (multi-instance consolidated view = NOT AVAILABLE at v1).
Postgres target bindingSpike used localfs.declare_file sink, not postgres.mount_table_target(managed_by="user"). The S1 spike already validated the Postgres path; this spike isolates the memo behaviour from target-binding effects.No re-test needed — orthogonal concerns.
Million-item scale24 fixtures. Memo lookup cost at 75k+ items not measured.Per the cocoindex docs, LMDB lookup is O(log N); should scale linearly with item count + LLM cost. Not a memo-substrate question — a connector-throughput question.
MemoStateOutcome two-phase validationCocoindex’s two-phase memo validation (cheap state check + expensive content hash) per _internal/memo_fingerprint.py exists but wasn’t exercised by this spike’s fn shapes (no custom __coco_memo_state__ declared).Phase 2 implementation will exercise naturally.
@coco.fn deps parameterThe deps parameter (for hash-tracking external dependencies) untested.Not needed for KH’s pure-content fns; deferred to Phase 2 if LLM-prompt-template or model-version tracking is wanted.
Discriminated-union Pydantic with ExtractByLlmPer phase-b-prerequisite-2-cocoindex-deep-dive §5 — separate STILL-OPEN. Not addressed here.Independent spike per Prereq 2 §5.

8.2 — What this spike CONFIRMS for KH Phase B

Section titled “8.2 — What this spike CONFIRMS for KH Phase B”
ClaimStatus
Cocoindex memo short-circuits cleanly when input fingerprint is unchanged across runs✅ Confirmed empirically
The fingerprint includes ALL function args (not magic content-hash)✅ Confirmed
FileLike input fingerprint = whole-file SHA → metadata edits invalidate✅ Confirmed
str content_text input fingerprint = content SHA → metadata edits hit memo✅ Confirmed
Memo is per-component (per-mount_each instance), not global per-content-hash✅ Confirmed
Version-bump cascade upstream✅ Confirmed
Edge cases (empty, unicode, collision pairs) handled cleanly✅ Confirmed

To re-run this spike from scratch (~5 minutes wall-clock):

Terminal window
mkdir -p $TMPDIR/kh-s9-spike
cd $TMPDIR/kh-s9-spike
python3 -m venv .venv && source .venv/bin/activate
PIP_USER=0 PIP_TARGET="" pip install 'cocoindex>=1.0.3'
# Place build_corpus.py, flow.py from this spike's harness here.
python3 build_corpus.py --edge-cases
COCOINDEX_DB="$(pwd)/coco_db" cocoindex update flow.py
# RUN 2 — no-op
COCOINDEX_DB="$(pwd)/coco_db" cocoindex update flow.py
# Mutate + RUN 3
python3 build_corpus.py --mutate
COCOINDEX_DB="$(pwd)/coco_db" cocoindex update flow.py
# Inspect counts
wc -l invocations.log inner_invocations.log

Harness files preserved in $TMPDIR/kh-s9-spike/ for the session lifetime. Not committed to repo per CLAUDE.md temp-file policy. Future re-runs require reconstructing the harness from this report’s §2.3 listing (intentionally complete enough to retype).


End of S9 spike report. Closes I3 in 00-synthesis-v2.md (PARTIAL — caveats in §7). Unblocks sidecar v1 promotion gate CONDITIONAL on layered fn-shape recommended in §7.2. Confidence 95%. No still-open items requiring escalation to main session; recommendations route into WP4 02-data-flow.md + 05-qa-flow.md.