Skip to content

T8 RESEARCH — Cocoindex flow scaffolding + Cloud Run sidecar

T8 RESEARCH — Cocoindex flow scaffolding + Cloud Run sidecar

Section titled “T8 RESEARCH — Cocoindex flow scaffolding + Cloud Run sidecar”

Spec slug (provisional): cocoindex-flow-scaffolding Subtask: ID-28.1 (RESEARCH precursor to {28.2 PRODUCT} → {28.3 TECH} → {28.4 PLAN}) Parent Task: ID-28 — T8 cocoindex flow scaffolding + Cloud Run sidecar deploy (docs/specs/id-31-canonical-pipeline-implementation-plan/PLAN.md §4.8) Critical-path position: Wave 4. Gates: T1 (Q-EX2 contract) + T2 (Q-OQR1-16 combined-PR migration) + T6 (q_a_extractions schema) + T3 (RLS-pattern apply). Now also gates T7 (Phew Q&A first-ingest) per RATIFIED-S243 Item 18. Author: task-planner (opus-4-7, isolation=worktree) on worktree-agent-ad123585d709d0dc6 from base commit 40e5cceb.


§R. S265 RE-GROUNDING — Cocoindex 1.0.3 reactive write path (ID-28.20)

Section titled “§R. S265 RE-GROUNDING — Cocoindex 1.0.3 reactive write path (ID-28.20)”

Author: task-planner (opus-4-7, thinking: max, isolation=worktree) on branch worktree-agent-a85602bcfdfecd08d, base commit e472c743. Date: 25/05/2026. Mandate: ID-28.20 — re-ground the canonical pipeline against the INSTALLED cocoindex source after ID-28 was marked done against a fictional API. Validate every API claim empirically; where the spec — INCLUDING THIS SUBTASK’S OWN MANDATE — contradicts installed reality, reality wins. Pinned version: cocoindex[postgres]==1.0.3 (requirements.txt), installed at /Users/liamj/Library/Python/3.14/lib/python/site-packages/cocoindex, cocoindex.__version__ == "1.0.3".

§R0. Headline — TWO fictions, not one; the real path is PROVEN end-to-end

Section titled “§R0. Headline — TWO fictions, not one; the real path is PROVEN end-to-end”

The ID-28.20 mandate identified one fiction (bind_target) and prescribed one fix (collect → export). Both the original fiction AND the prescribed fix are wrong. This is a recursive instance of the exact failure 28.20 exists to correct: the mandate’s collect → export is the PUBLIC CocoIndex dataflow API, which the installed 1.0.3 does not expose. The installed 1.0.3 is the reactive App / Component / Target-reconciliation API.

The real, empirically-proven write path is:

coco.App(name, main_fn).update_blocking() # runs main_fn as the root component
└─ main_fn: mount_table_target(...) → TableTarget handle
localfs.walk_dir(path, live=…, recursive=True).items() → keyed source feed
coco.mount_each(per_file_fn, source.items(), target) # one component PER source item
└─ per_file_fn(file, target): read → transform (chunk/extract/embed) → build row dict
target.declare_row(row={... , "op_id": <run uuid>}) # op_id is a PLAIN ROW FIELD
└─ context (asyncpg.Pool / connection) provided env-scope via:
@coco.lifespan def _ls(builder): builder.provide(DB_CTX, pool); yield

I proved this end-to-end with a live single-file ingest probe (sqlite connector — identical declare_row / mount_table_target shape as postgres, no external DB needed). The probe ran the App, mount_each invoked the per-file fn once per source file, each row landed in the DB with op_id stamped. PROBE PASS. See §R3 for the probe and §R4 for the memoisation subtlety it surfaced.

Consequence for the as-shipped code: scripts/cocoindex_pipeline/flow.py app_main() (L671-765) is built ENTIRELY on the fictional dataflow API — not just the bind_target calls. source.transform(...) (L684, L708-710), content_text.bind_target(...) (L757-765), flow["op_id"] (L758-764), and the 2-arg coco.use_context(DB_CTX, coco_pool) async-CM form (L670) are ALL fictional / wrong-arity in 1.0.3. Stage-6 writes nothing today, and the fix is a structural rewrite of app_main(), not a line-level bind_target → declare_row substitution. This is the “materially harder than the memo implies” case the brief asked me to flag loudly. See §R6.

§R1. Empirical verification log (OQ-3 import-and-call discipline)

Section titled “§R1. Empirical verification log (OQ-3 import-and-call discipline)”

All checks run against cocoindex==1.0.3, 25/05/2026, sandbox-disabled where LMDB is touched.

#Symbol / claimMethodResult
R1.1cocoindex.flow, cocoindex.DataSlice, .transform, collect, export, add_collector, flow_def, FlowBuilderhasattr on cocoindex module + __all__ enumerationABSENT — none exist. The public __all__ is the reactive App surface (App, map, mount, mount_each, mount_target, use_context, use_mount, fn, lifespan, declare_target_state, LiveMapFeed/View, …).
R1.2bind_target on any DataSlicen/a — no DataSlice type existsABSENT (fiction confirmed).
R1.3postgres.TableTarget.declare_row(*, row: RowT)inspect.signature on cocoindex.connectors.postgres._target.py:1164PRESENT. def declare_row(self, *, row: RowT) -> None. Row = dict / dataclass / NamedTuple / Pydantic; must include all PK columns. Internally calls coco.declare_target_state(...).
R1.4postgres.mount_table_target(db, table, schema, *, pg_schema_name, managed_by) asyncinspect.signature, _target.py:1332PRESENT, async. Returns ready-to-use TableTarget.
R1.5ManagedBy.USER semanticscocoindex.connectorkits.target.ManagedBy; _target.py:1314 docstringPRESENT. USER = "user" ⇒ “table must exist, CocoIndex only manages rows” (no DDL). SYSTEM = "system" ⇒ CocoIndex creates/drops the table. KH wants USER.
R1.6TableTarget.declare_vector_index(*, column, metric, method, …) (Stage-4 embedding hook)inspect.signature, _target.py:1194PRESENT. Native pgvector-index declaration; metric ∈ {cosine,l2,ip}, method ∈ {ivfflat,hnsw}. Index named {table}__vector__{name}.
R1.7coco.use_context(key) arityinspect.signature(key: ContextKey[T]) -> TSINGLE-ARG, read-only. The 2-arg write form use_context(key, value) used as an async-CM in flow.py:670 is WRONG. Writes happen env-scope via EnvironmentBuilder.provide(key, value) / provide_with / provide_async_with inside @coco.lifespan.
R1.8coco.App(AppConfig(...), main_fn) shapeApp.__init__ accepts `strAppConfigfirst-positional +main_fnsecond;AppConfig` fields = {name, environment, max_inflight_components}
R1.9cocoindex.functions.ExtractByLlm / LlmSpec / LlmApiTypegrep installed treeABSENT — confirms docs/research/cocoindex-1.0.3-extractbyllm-spec-reality-investigation.md (S256). KH Path A (direct anthropic in @coco.fn) is correct.
R1.10cocoindex.ops native helpersimport + dir()ops.textRecursiveSplitter, SeparatorSplitter (chunking, no extra dep). ops.litellmLiteLLMEmbedder/LiteLLMTranscriber (needs litellm, present in requirements.txt; embedding/transcription only — no LLM extraction). ops.sentence_transformersSentenceTransformerEmbedder. ops.entity_resolutionresolve_entities / LLM-pair resolver (needs faiss).
R1.11mount_each(fn, items, *args) per-item-component semanticsread api.py:445-529 + live probeCONFIRMED. Mounts one independent component per (key, value) in a keyed iterable; calls fn(value, *args) per item. LiveMapFeed/LiveMapView items → internal LiveComponent for fs-watch. map() (api.py:532) is pure concurrent execution, NO components — wrong primitive for the write path.
R1.12localfs.walk_dir(path, *, live, recursive).items() feed shape_source.py:147-155 + live probe; File attrsCONFIRMED. .items() yields (relative_path: str, File); live=TrueLiveMapView (fs-watch). File exposes file_path, read(), read_text(), content_fingerprint, sizeNOT .path. recursive defaults False (CLAUDE.md gotcha holds).

§R2. The real write contract — canonical pattern (bundled connector example)

Section titled “§R2. The real write contract — canonical pattern (bundled connector example)”

The bundled turbopuffer connector (cocoindex/connectors/turbopuffer/_target.py:486-495) documents the canonical pattern verbatim:

@coco.fn
def process_doc(doc: Doc, target: NamespaceTarget) -> None:
target.declare_row(turbopuffer.Row(id=doc.id, vector=doc.embedding, attributes={...}))

Mapped to KH / postgres:

DB_CTX: coco.ContextKey[asyncpg.Pool] = coco.ContextKey("kh_pipeline_db") # identity handle (unchanged)
@coco.fn(memo=True) # memo=True ⇒ incremental skip on unchanged input
async def ingest_file(file: localfs.File, ci_target: TableTarget, qa_target: TableTarget,
sd_target: TableTarget) -> None:
content_bytes = await file.read()
content_text = await convert_binary_to_markdown(file) # P-3 adapter (already @coco.fn)
classification = await extract_classification(content_text) # Path A — direct anthropic in @coco.fn
qa = await extract_qa_form(content_text)
meta = current_flow_meta() # op_id from FLOW_META_CTX (flow_context.py)
sd_target.declare_row(row={"id": <sd_uuid>, "storage_path": ..., "op_id": meta.op_id})
ci_target.declare_row(row={"id": <ci_uuid>, "content_text": content_text,
"embedding": <vec(1024)>, "source_document_id": <sd_uuid>,
"op_id": meta.op_id})
qa_target.declare_row(row={"id": <qa_uuid>, "source_content_item_id": <ci_uuid>,
"extractor_kind": ..., "extracted_question_text": ...,
"extraction_metadata": {...}, "op_id": meta.op_id})
async def app_main() -> None:
ci = await mount_table_target(DB_CTX, "content_items", CONTENT_ITEMS_SCHEMA, managed_by=ManagedBy.USER)
qa = await mount_table_target(DB_CTX, "q_a_extractions", Q_A_EXTRACTIONS_SCHEMA, managed_by=ManagedBy.USER)
sd = await mount_table_target(DB_CTX, "source_documents", SOURCE_DOCUMENTS_SCHEMA, managed_by=ManagedBy.USER)
ci.declare_vector_index(column="embedding", metric="cosine", method="hnsw") # Stage-4 index hook (optional)
source = localfs.walk_dir(source_path, live=True, recursive=True)
async with bind_flow_meta(op_id=run_op_id), bind_retry_counter(counter):
await coco.mount_each(ingest_file, source.items(), ci, qa, sd)
@coco.lifespan # env-scope DB pool provisioning
def _kh_lifespan(builder: coco.EnvironmentBuilder):
pool = asyncio.get_event_loop().run_until_complete(asyncpg.create_pool(_build_dsn()))
builder.provide(DB_CTX, pool); yield # or builder.provide_async_with(DB_CTX, cm)
KH_PIPELINE_APP = coco.App(coco.AppConfig(name="kh_pipeline"), app_main)

Key contract facts (all verified §R1):

  • op_id is just a row field. Sourced from FLOW_META_CTX (already populated by bind_flow_meta() in flow_context.py, which survives re-grounding unchanged). No bind_target(op_id=), no flow["op_id"].
  • managed_by=ManagedBy.USER ⇒ cocoindex writes rows only, never DDL. KH migrations own the schema.
  • content_text_hash is GENERATED ALWAYS — must stay OMITTED from the TableSchema columns (existing schema declarations at flow.py:535-568 already do this correctly).
  • DB pool binds env-scope via @coco.lifespan + builder.provide(DB_CTX, pool) — NOT per-flow use_context(key, value).

Probe file /tmp/coco_probe/probe.py (sqlite connector). Two source files doc1.txt, doc2.txt. Pattern: walk_dir().items()mount_each(per_file_fn, items, target) → per-file @coco.fn reads + uppercases + declare_row(row={id, body, op_id}); DB pool/conn provided via @coco.lifespan; table pre-created with managed_by=USER. Ran app.update_blocking(full_reprocess=True, live=False) with COCOINDEX_DB set.

Result:

ROWS: [('doc1.txt', 'HELLO WORLD FROM DOC ONE', '7b71…002b'),
('doc2.txt', 'SECOND DOCUMENT BODY TEXT', '7b71…002b')]
PROBE PASS: declare_row write path proven end-to-end

This proves: App execution, @coco.lifespan context provisioning, mount_each per-item-component fan-out, transform inside the per-item fn, declare_row upsert, and op_id-as-field stamping — the complete Stage-1→Stage-6 skeleton, end-to-end, against installed 1.0.3.

§R4. CRITICAL FINDING — memoisation vs op_id semantics (Inv-11/12 refinement)

Section titled “§R4. CRITICAL FINDING — memoisation vs op_id semantics (Inv-11/12 refinement)”

A follow-up probe (same app name, full_reprocess=False, unchanged source bytes, but a new op_id on each run, @coco.fn(memo=True)):

op_id_intended=aaaaaaaa rows_op_id=['aaaaaaaa', 'aaaaaaaa'] # run 1 — writes aaaa
op_id_intended=bbbbbbbb rows_op_id=['aaaaaaaa', 'aaaaaaaa'] # run 2 — row op_id STAYS aaaa

The memoised fn is correctly SKIPPED when source bytes are unchanged, so declare_row is not re-invoked and the row’s op_id retains the value from the run that last MATERIALLY changed it. This is the correct incremental-pipeline behaviour, but it refines the Inv-11 mechanism: op_id records “the run that produced/updated this row”, NOT “the most recent run that scanned this row”. A no-op re-ingest does not bump op_id. This is CONSISTENT with Inv-11’s wording (“produced or updated by a cocoindex pipeline run”) — an unchanged row was neither — but the original spec assumed flow-scope stamping (bind_target(op_id=flow['op_id'])) which would have implied “every run stamps every touched row”. The reactive+memo reality is strictly better for provenance forensics but must be stated explicitly so the Checker tests the right thing. Escalated as OQ-A for Liam (see proposal). A corollary: full_reprocess=True re-runs all fns and WILL re-stamp every row with the current run’s op_id (initial full corpus ingest stamps uniformly).

§R5. Native Stage-4 / Stage-5 surface (criticality assessment for re-ingest)

Section titled “§R5. Native Stage-4 / Stage-5 surface (criticality assessment for re-ingest)”
  • Stage-4 embedding. Two native routes: (a) cocoindex.ops.litellm.LiteLLMEmbedder (needs litellm, in requirements.txt) or ops.sentence_transformers.SentenceTransformerEmbedder; (b) KH calls its own embedder inside ingest_file and writes the vector(1024) into the embedding column via declare_row. The embedding column already exists in CONTENT_ITEMS_SCHEMA. TableTarget.declare_vector_index(column="embedding", …) declares the pgvector index. Criticality: REQUIRED for search over the re-ingested corpus — a corpus written with NULL embedding cannot serve vector search. But it is a within-ingest_file transform + one declare_row column + one index declaration, not a structural change. Recommend it lands as a dedicated subtask AFTER the bare write path is proven (so the write path is de-risked first). text-embedding-3-large / vector(1024) per CLAUDE.md.
  • Stage-5 entity resolution. cocoindex.ops.entity_resolution.resolve_entities exists but needs faiss (NOT in requirements.txt today). entity_mentions extraction (the EXTRACT step) is Path A and feeds the q_a_extractions / entity tables; entity RESOLUTION (dedup/canonicalisation across mentions) is a distinct downstream concern. Criticality: DEFERRABLE for v1 re-ingest — resolution is a quality-enrichment pass, not a prerequisite for the corpus being searchable. Recommend deferring (keep the TODO(28.13+) posture) unless Liam states the re-ingested corpus must have resolved entities at v1. Escalated as OQ-C.
    • S265 ratification: OQ-C OVERRIDDEN by Liam — Stage-5 entity resolution IS in scope (subtask 28.29); faiss to be pinned.

§R6. Scope reality — this is a STRUCTURAL rewrite of app_main() (loud flag)

Section titled “§R6. Scope reality — this is a STRUCTURAL rewrite of app_main() (loud flag)”

The memo framed 28.20 as “correct the bind_target sketch”. The installed reality is larger:

  1. The entire reactive flow in app_main() (flow.py:671-765) is fictional dataflow API and must be rewritten to the mount_each + per-item-@coco.fn + declare_row shape. The transforms (convert_binary_to_markdown, the three extractors) move from flow-scope .transform() chaining INTO the per-item ingest_file body.
  2. coco.use_context(DB_CTX, coco_pool) as an async-CM (flow.py:670) is wrong-arity; DB pool provisioning moves to a @coco.lifespan env builder using builder.provide(...). This touches how server.py / __main__.py boot the App (they must register the lifespan on the environment).
  3. flow_context.py (FLOW_META_CTX / bind_flow_meta / bind_retry_counter / current_flow_meta) survives unchanged — it is a stdlib contextvars substrate with no fictional API. The op_id CONSUMPTION site moves from the (fictional) bind_target(op_id=) to the row dict inside ingest_file.
  4. The TableSchema declarations (flow.py:535-568), _build_dsn(), the rollup/webhook helpers (_emit_pipeline_run_webhook, _record_extraction_*, _classify_stage_exception, _emit_stage_error_log), and the _emit_upsert_log() contract all survive; only their call-sites/wiring change.

The good news: the substrate (schemas, op_id contextvar, DSN, rollup helpers, error classification, Path A extractors per S256) is sound. The rewrite is concentrated in app_main() + the App/lifespan boot wiring. The probe proves the target shape works. But this is multi-subtask work with a shared flow.py surface (serialisation pressure — see decomposition).

§R7. Layered retry / observability reality (Inv-23/24/25, P-OQ2/P-OQ3 correction)

Section titled “§R7. Layered retry / observability reality (Inv-23/24/25, P-OQ2/P-OQ3 correction)”

The spec (Inv-23, P-OQ2 default “cocoindex defaults: 3 retries…”) implies a single cocoindex-native retry policy covering all stages. The installed reality is layered:

  • cocoindex native LLM HTTP-429 auto-retry applies ONLY to LLM/embedding calls cocoindex ITSELF issues (via ops.litellm). KH uses Path A (direct anthropic.AsyncAnthropic inside @coco.fn), so cocoindex’s LLM retry does NOT cover KH’s extraction calls.
  • KH’s own tenacity wrapper (_anthropic_retry in extraction.py, with the before_sleep hook bumping the bind_retry_counter flow-scope counter) is the OPERATIVE retry for the Path A anthropic calls. This is real and works.
  • Postgres writes have ZERO per-row retry. TableTarget._apply_actions (private) issues the UPSERT; there is no public retry primitive and no completion callback. A transient PG failure surfaces as an exception that fails the component (cocoindex’s cross-update durability re-attempts the component on the next update cycle, but there is no in-run per-row PG retry).

P-OQ2 correct framing: retry is KH-tenacity-owned for LLM (3 attempts / exponential backoff — KH’s choice, in extraction.py), cocoindex-native for any ops.litellm calls, and absent for PG. P-OQ3 (dead-letter): no KH pipeline_failures table (COCO.7); the dead-letter surface is pipeline_runs.status='failed' rollup + structured logs + cocoindex’s internal LMDB tracking. Both defaults in PRODUCT remain DIRECTIONALLY right but the “cocoindex defaults 3 retries” prose is misleading and must be corrected to the layered reality.

§R8. _emit_upsert_log() live-wiring reality (former ID-44.1, low criticality)

Section titled “§R8. _emit_upsert_log() live-wiring reality (former ID-44.1, low criticality)”

_emit_upsert_log() (flow.py:119) is a sound helper contract (28.10, 8/8 unit tests). The blocker is unchanged from S255: cocoindex 1.0.3 exposes no public per-row UPSERT completion callbackTableTarget._apply_actions is private. To fire _emit_upsert_log per UPSERT, KH would have to subclass TableTarget or wrap the private TargetActionSink.from_async_fn. The lowest-friction v1 stance: emit a per-declare_row-call log line INSIDE ingest_file (we control that site) carrying {op_id, table, row_id, operation: "upsert"} — note this logs “declared” not “applied”, and cannot distinguish INSERT vs UPDATE (the reconciler decides that privately). Criticality: LOW. op_id columns + per-failure logs already cover provenance; the per-row INSERT/UPDATE distinction is a nice-to-have. Recommend the declare_row-site log line for v1, documenting the “declared ≠ applied” + “no INSERT/UPDATE distinction” caveats.

§R9. Original-intent reconciliation (02-data-flow / 0.9 N7)

Section titled “§R9. Original-intent reconciliation (02-data-flow / 0.9 N7)”

docs/plans/phase-0-investigation/architecture/02-data-flow.md §5.1 (N7 hybrid) says “cocoindex emits a stable op_id per pipeline run and propagates it into the Postgres target columns it writes.” Reality: cocoindex does NOT emit an op_id — KH generates run_op_id = uuid4() (flow.py:644) and writes it as a row field. This MATCHES N7’s own caveat (0.9-decision-graph.md §11.4.1 / deep-dive §1.3): “op_id propagation pattern is a Postgres-side implementation choice — cocoindex doesn’t pick.” So the implemented reality (KH-generated op_id, written as a declare_row field) is the faithful realisation of N7; the “cocoindex emits op_id” phrasing was an assumption that the re-grounding corrects. The 6-stage topology (§3) and op_id-round-trip intent (Inv-12) are unchanged and achievable. 02-data-flow.md §3.1’s reference to ExtractByLlm as the extractor is superseded by Path A per S256 (already recorded).


§1. cocoindex 1.0.3 integration constraints

Section titled “§1. cocoindex 1.0.3 integration constraints”

cocoindex 1.0.3 requires dangerouslyDisableSandbox: true for both PyPI install and Rust-engine LMDB ops-DB startup in dev. Source-of-truth statements:

  • CLAUDE.md Gotchas → “cocoindex 1.0.3 requires dangerouslyDisableSandbox: true for both PyPI install and Rust-engine LMDB startup in dev. localfs.walk_dir defaults recursive=False — explicit recursive=True needed for nested corpora.”
  • docs/plans/phase-0-investigation/0.9-spike-S1-cocoindex-schema-coupling.md:135 — S230 install line: spike/cocoindex_s1/.venv/bin/pip install 'cocoindex[postgres]' with dangerouslyDisableSandbox: true required per S229 S2 gotcha.
  • docs/plans/phase-0-investigation/0.9-spike-S2-cocoindex-folder-binding.md:41 — S229 reproduction of the install + startup sandbox-disable requirement.

Operational implication: any T8 build, CI step, or dev session that imports cocoindex (or that triggers its Rust engine via App.update() / App.start()) must either (a) opt out of the Claude Code sandbox via dangerouslyDisableSandbox: true on the Bash invocation, or (b) live entirely in the Cloud Run runtime where the sandbox does not apply. Local-dev tests and the harness at spike/cocoindex_s1/probe_managed_by_user.py follow path (a).

§1.2 localfs.walk_dir(recursive=False) default + live=True watch

Section titled “§1.2 localfs.walk_dir(recursive=False) default + live=True watch”
  • docs/plans/phase-0-investigation/0.9-spike-S2-cocoindex-folder-binding.md:124-128 surfaces walk_dir defaulting recursive=False; explicit recursive=True required for nested folder corpora. Phew’s Q&A markdown corpus is nested (T7 dependency), so T8’s source-binding adapter must set recursive=True.
  • 02-data-flow.md:32 — cocoindex binds via localfs.walk_dir(live=True) (LocalFS or Cloud Run mounted share) emitting file-change events. Layer-2 admin-metadata binding per phase-b-prerequisite-2-cocoindex-deep-dive.md §1.1.
  • UC10 cadence (per S2 0.9-spike-S2-cocoindex-folder-binding.md:130-134): native fs-watch via watchfiles 1.1.1 (FSEvents on macOS, inotify on Linux, 1600 ms debounce). Real-time for v1 localfs-only; remote-source connectors (SharePoint / Notion / Drive / Box) are NOT in v1 — defer to v1.1.

§1.3 Layered fn-shape (COCO.10 / S9 spike)

Section titled “§1.3 Layered fn-shape (COCO.10 / S9 spike)”

Inner-tier @coco.fn extraction functions MUST consume content_text: str (NOT FileLike) so the memoisation key is the file contents, not the file handle. This preserves per-tier idempotency: edits to host-file metadata (mtime, owner, etc.) do not re-trigger inner extraction work. Sources:

  • 02-data-flow.md:67 — “Inner-tier functions must accept content_text: str (not FileLike) per S9 spike layered-fn-shape requirement (COCO.9 + COCO.10 CLOSED-CONDITIONAL).”
  • 03-tech-stack.md:106 — “Memo scoping is per-component-path, not global content-hash dedup. The sidecar v1 promotion gate (COCO.10) is CLOSED-CONDITIONAL on the layered fn-shape.”
  • scripts/ontology-sync/parse-flow.py:6-15,98-104the canonical KH stub for the layered fn-shape (file-tier outer fn process_ontology_file(file: FileLike) -> ParsedCV; inner fns parse_cv_frontmatter(content_text: str) and validate_cv_against_yaml(content_text: str, expected_keys: list[str])). This stub is the in-repo reference implementation T8 should mirror for the canonical-pipeline outer/inner split.

§1.4 Existing @coco.fn examples in the repo

Section titled “§1.4 Existing @coco.fn examples in the repo”

A repo-wide sweep (find . -type f -name '*.py' -exec grep -l '@coco' {} + plus find . -type f \( -name '*.py' -o -name '*.ts' \) | xargs grep -l 'cocoindex\|@coco\.') returns exactly two files:

FileRole
scripts/ontology-sync/parse-flow.pyCanonical layered-fn-shape stub (no live wiring; documents the pattern T8 must adopt). Outer process_ontology_file(file); inner parse_cv_frontmatter(content_text: str) + validate_cv_against_yaml(content_text: str, expected_keys). Author note: “When wired live: import cocoindex as coco; ontology_sync_app = coco.App(name='ontology-sync'). Today: ontology_sync_app is intentionally absent.”
spike/cocoindex_s1/probe_managed_by_user.pyS230 live-test harness for Scenario A (managed_by="user"). Imports cocoindex as coco, cocoindex.connectors.postgres.{ColumnDef, TableSchema, declare_table_target, mount_table_target}, cocoindex.connectorkits.target.ManagedBy. Uses coco.ContextKey[asyncpg.Pool], coco.use_context, coco.start, coco.AppConfig(name=..., main_fn=...). Demonstrates single-row upsert via target.upsert((pk_key,), {row_dict}). This is the only existing live wiring shape in the repo and is the canonical reference for T8’s first Phase-2 step.

Note: requirements.txt does NOT currently list cocoindex — T8 is the first work-stream to add cocoindex (or cocoindex[postgres]) as a production Python dependency. scripts/kb_pipeline/*.py (extract.py, classify.py, chunk.py, embed.py, pipeline.py, etc.) are the legacy pipeline that T14 retires after T8 stabilises — T8 must not break these as they remain the live ingest path until cutover.

§1.5 cocoindex 1.0.3 API drift caveats (S2-confirmed)

Section titled “§1.5 cocoindex 1.0.3 API drift caveats (S2-confirmed)”

docs/plans/phase-0-investigation/0.9-spike-S2-cocoindex-folder-binding.md:123-128 flags four critical drifts from the pre-S2 evaluation docs that T8 must respect:

  1. sourcesconnectors rename — old from cocoindex.sources import localfs is stale; the harness uses from cocoindex.connectors.postgres import ... but the spike doc also notes a connectors namespace path.
  2. @flow_defApp(name, main_fn) — flow-definition pattern is now App-class based. Reference shape: coco.start(coco.AppConfig(name="...", main_fn=app_main)) per probe_managed_by_user.py:246.
  3. SQLite ops-DB → LMDB — LMDB is single-writer at the process level. Spike S14 (docs/plans/phase-0-investigation/0.9-spike-S14-cocoindex-concurrency.md) closed this as single-orchestrator-instance with isolated per-instance LMDB (10 concurrent writers worked cleanly empirically; the original S2 “LMDB hard-blocks multi-worker” framing was overcautious). v1 Cloud Run topology: min_instances=1, max_instances=1 OR scheduled Cloud Run Job. Each instance gets its own ephemeral LMDB; re-fingerprint cost per cold-start (~7 s for 35-file canonical corpus) is acceptable at v1 scale.
  4. walk_dir(recursive=False) default — covered in §1.2.

§1.6 Sample call shapes (engine surface T8 will use)

Section titled “§1.6 Sample call shapes (engine surface T8 will use)”

From probe_managed_by_user.py + parse-flow.py + 02-data-flow.md §3.1:

import cocoindex as coco
from cocoindex.connectors.postgres import (
ColumnDef, TableSchema, mount_table_target,
)
from cocoindex.connectorkits.target import ManagedBy
DB_CTX = coco.ContextKey[asyncpg.Pool]("db_pool")
table_schema = TableSchema(
columns={
"id": ColumnDef(type="uuid", nullable=False),
"content_text": ColumnDef(type="text", nullable=False),
"embedding": ColumnDef(type="vector(1024)", nullable=True),
"op_id": ColumnDef(type="uuid", nullable=True), # per N7 + §3.3
# GENERATED ALWAYS cols (e.g. content_text_hash) OMITTED — per S1 §1
},
primary_key=("id",),
)
async def app_main():
async with coco.use_context(DB_CTX, coco_pool):
target = await mount_table_target(
DB_CTX, "content_items", table_schema,
managed_by=ManagedBy.USER, # KH owns DDL; engine row-level only
)
# @coco.fn outer (file-tier) + inner (content_text-tier) per S9 layered shape
...
await coco.start(coco.AppConfig(name="kh_pipeline", main_fn=app_main))

§2.1 Footprint constraints — load-bearing for split-runtime architecture

Section titled “§2.1 Footprint constraints — load-bearing for split-runtime architecture”

Per 02-data-flow.md §4.1 + 03-tech-stack.md §3.2 + phase-b-prerequisite-2d- docling-bakeoff.md §6:

  • Docling 1.8 GB on-disk footprint (layout-heron + docling-models). MIT-licensed.
  • pullmd ~3.7 GB Playwright sidecar when enabled. AGPL v3 (calls as separate self-hosted network service per 03-tech-stack.md §7.3 — network-service clause does not propagate to KH platform code).
  • cocoindex engine (Rust) — LMDB ops-DB; dangerouslyDisableSandbox: true required in dev (production / Cloud Run is sandbox-free).
  • Vercel function-bundle limit: 250 MB — Docling alone is ~7.2× this limit, so in-Vercel-function ingest is structurally impossible. The Cloud Run sidecar is mandatory (02-data-flow.md §4.1; 03-tech-stack.md §3.2 “this constraint is not a code-organisation preference — it is the load-bearing reason the platform is split across two runtimes”).

Per CLAUDE.md “Deployment” + .github/workflows/cloud-run-deploy.yml:1-100 + docs/runbooks/cloud-run-phase-1.md:

  • Cloud Run projects already provisioned: kh-prod-494815 (main branch) + kh-staging-494815 (production-readiness branch). WIF auth (no JSON keys). Per-tenant manifests at cloudrun/jobs/{prod,staging}-{kpf,phew}.yaml. Cloudbuild pipeline at cloudrun/cloudbuild.yaml (single image variant kh-pipeline, ~3.3 GB, default entrypoint python3 scripts/ingest.py).
  • CI/CD wiring: .github/workflows/cloud-run-deploy.yml triggers on push to main (→ kh-prod-494815) or production-readiness (→ kh-staging-494815); paths guarded to scripts/**/*.py, requirements.txt, cloudrun/**, .gcloudignore.
  • Build mode: buildpack (gcr.io/buildpacks/python) per D-RUN-2 primary path; Dockerfile escape hatch contingent on R-10 (bert-score buildpack failure).

Gaps vs T8 sidecar requirement (NOT yet present):

  1. cocoindex not in requirements.txt — net-new dependency for the sidecar. Will pull cocoindex[postgres] + transitive deps (asyncpg + LMDB native). Pre-flight sizing impact: ~50-150 MB.
  2. Docling not in requirements.txt — net-new 1.8 GB binary + model download (cold start ~44.75 s per phase-b-prerequisite-2d-docling-bakeoff.md §6; mitigated by @coco.fn(memo=True) + Docling-model pre-warm in container image).
  3. pullmd is currently external (self-hosted Docker stack per 03-tech-stack.md §7.3). T8 must decide: (a) keep pullmd as a separate Cloud Run service that the cocoindex sidecar calls via HTTP (current shape per 03-tech-stack.md §7.3 license logic), OR (b) co-locate in the cocoindex sidecar image. Recommendation: keep separate per the AGPL “network service” licence boundary — that boundary is load-bearing for KH’s license posture.
  4. Cocoindex’s LMDB ops-DB requires persistent or per-instance storage. Per S14 (0.9-spike-S14-cocoindex-concurrency.md): v1 ships isolated per-instance ephemeral LMDB on min_instances=1, max_instances=1 Cloud Run Service OR scheduled Cloud Run Job. Re-fingerprint cost per cold-start is acceptable at v1 scale; no queue infra needed. Option (b) — persistent LMDB to Cloud Storage is rejected for v1 per S14 (latency hit + complexity).
  5. Job vs Service shape decision: Cloud Run Job (batch invocation per scheduled cron) vs Cloud Run Service (long-running container with cocoindex engine listening to fs-watch events). Phew Q&A first-ingest (T7) is one-shot — Job semantics fit. Recurring source-binding observation (UC10 fs-watch) is continuous — Service semantics fit. Recommendation: Cloud Run Service for the cocoindex engine (continuous fs-watch + LMDB), Cloud Run Jobs for any one-shot batch operations. See §6 open question O-Q4 for the v1 split.
  6. Per-tenant manifest expansion — the existing 4 manifests ({prod,staging}-{kpf,phew}.yaml) cover the legacy scripts/ingest.py pipeline. T8 likely needs a new manifest family (e.g. {prod,staging}-{kpf,phew}-cocoindex.yaml) for the cocoindex Service, deployed alongside the existing legacy Job manifests (parallel-running during T14 cutover window).

The Cloud Run baseline is 80% ready for T8: projects + WIF + cloudbuild + manifest convention all in place. T8 net-new work concentrates in: (a) requirements.txt deps (cocoindex, docling, model-download wiring), (b) per-tenant Service manifests for the cocoindex engine, (c) container image size budget verification post-dep-add (cloudbuild current is ~3.3 GB; +1.8 GB Docling + ~150 MB cocoindex = ~5.3 GB target — within Cloud Build limits but worth verifying), and (d) sidecar-vs-Service split decision documented in TECH.md.


The op_id propagation pattern is the hybrid ratified per N7 RESOLVED-S236 (docs/plans/phase-0-investigation/architecture/02-data-flow.md:115-124; docs/plans/phase-0-investigation/0.9-decision-graph.md §11.4.1 cited in 02-data-flow.md:117):

  • Cocoindex per-flow op_id — cocoindex emits a stable op_id per pipeline run and propagates it into the Postgres target columns it writes. Every content_items row, every q_a_extractions row, every source_documents state update written by a given cocoindex run carries that run’s op_id. Pipeline-side correlation (“which rows did run X write?”) is answerable directly from the target tables.
  • Trigger-driven audit_log — KH’s audit_log table captures platform-wide audit events via Postgres AFTER INSERT / UPDATE / DELETE triggers on governed tables. Fires independently of whether the write came from cocoindex, the upload route, a governance cron, or a user edit. Provides audit cohesion across sources that cocoindex’s per-flow op_id alone cannot supply (e.g. direct UI edits have no cocoindex op_id).

The hybrid means both signals are present for cocoindex-originated writes; for non-cocoindex writes (direct UI edits, governance cron updates), only the audit_log entry is present (the correct + intended state).

§3.2 Why trigger-driven (not app-stamped)

Section titled “§3.2 Why trigger-driven (not app-stamped)”

Per 02-data-flow.md:126-131:

  1. Coverage completeness — a trigger fires on every governed-table write regardless of call site. App-stamped audit creates silent gaps in any new write path that forgets to audit_log.insert().
  2. Cocoindex write-path independence — cocoindex’s postgres.mount_table_target() UPSERT is an external write KH application code does not intermediate. App-stamping would couple the audit system to cocoindex target-table registration; trigger-driven decouples audit from writer identity.

App-stamped framing is explicitly listed as an anti-pattern in 02-data-flow.md:289 (“App-stamped audit_log writes (without trigger) — A trigger fires on every governed-table write regardless of call site; app-stamped audit creates silent gaps in new write paths — N7 RESOLVED-S236”).

§3.3 Column-level + trigger-level surface inventory for T8

Section titled “§3.3 Column-level + trigger-level surface inventory for T8”
SurfaceWhere it landsOwner / statusT8 dependency
content_items.op_id column (uuid, NULLABLE)04-workspace-types.md schema + Q-OQR1-16 combined-PR migration per 02-data-flow.md:135T2 (combined-PR) — RATIFIED-S236; column-level shape STILL-OPEN pending Q-OQR1-16 migrationT8 MUST verify the column is present post-T2 apply; T8’s TableSchema in the cocoindex mount declaration adds "op_id": ColumnDef(type="uuid", nullable=True)
q_a_extractions.op_id columnT6 schema (docs/specs/id-31-canonical-pipeline-implementation-plan/PLAN.md §4.6 — SHIPPED-S250 migration 20260520225456_t6_q_a_pairs_full_schema.sql)T6 — verify whether T6 migration includes op_id on q_a_extractions or whether T8 follow-up DDL is required (gap-flag)T8 acceptance criterion: row written by cocoindex carries the run’s op_id
source_documents.op_id columnLikely needs ALTER as part of T8 (status-update writes from cocoindex’s content-hash signal)Not yet confirmed in landed migrations; verify pre-T8 OR add as T8 follow-up DDLT8 open question — see §6 O-Q1
audit_log table schemaPlatform-level governance table — NOT part of Q-OQR1-16 combined-PR scope per 02-data-flow.md:135-136Governed separately (referenced in docs/plans/phase-0-investigation/supabase-db-action-items.md Item 2) — v1 uses RAISE LOG from rls_auto_enable() per RLS-PATTERN P-5 [DEFERRED-v1.1]T8 does NOT need audit_log row-level integration to ship; structured-log shipping via Cloud Run sidecar log ingest is the v1 observability path
AFTER INSERT/UPDATE/DELETE triggers on content_items / q_a_extractions / source_documentsAlready in place for content_history_auto_version per 01-spike-S1 §4 (“All existing FKs + CHECK constraints + triggers + RLS continue to enforce”); audit_log AFTER triggers are governed separatelyInherited from existing schema; cocoindex INSERT ... ON CONFLICT DO UPDATE writes DO fire all existing triggersT8 acceptance criterion: trigger fires on cocoindex-driven inserts/updates (verify via integration test)

Gotcha (S1 §4): content_items.content_text_hash is GENERATED ALWAYS — must be omitted from the cocoindex TableSchema column declarations or the engine will attempt to write the column and PG will reject with cannot insert a non-DEFAULT value into column (per CLAUDE.md Supabase gotcha).

Gotcha (02-data-flow.md §3.1 + S1 §4): cocoindex writes via row-level INSERT ... ON CONFLICT DO UPDATE SET ... (not bulk COPY). Triggers fire per row; CHECK violations bubble up as asyncpg.PostgresError (no engine-side suppression).


§4.1 Spike #1 — cocoindex schema-coupling (Scenario A)

Section titled “§4.1 Spike #1 — cocoindex schema-coupling (Scenario A)”

Source: docs/plans/phase-0-investigation/0.9-spike-plan.md §1 (docs/plans/phase-0-investigation/0.9-spike-S1-cocoindex-schema-coupling.md — full spike report).

Status:CLOSED-S230 — SCENARIO A CONFIRMED. Cocoindex offers a first-class managed_by="user" mode (ManagedBy.USER per cocoindex.connectorkits.target.ManagedBy enum) in which the engine never emits DDL against the bound table. Confirmation is verbatim from:

  • cocoindex/connectors/postgres/_target.py:1313-1315 docstring — “managed_by: Whether the table is managed by ‘system’ (CocoIndex creates/drops it) or ‘user’ (table must exist, CocoIndex only manages rows).”
  • cocoindex/connectorkits/statediff.py:128resolve_system_transition returns None whenever t.desired.managed_by == "user", causing the diff() step to skip every DDL action (CREATE / DROP / ALTER all unreachable).

Implications for T8 (0.9-spike-S1.md §4):

  1. No schema redesign requiredcontent_items, content_chunks, source_documents, q_a_extractions, entity_mentions, entity_relationships all stay under KH ownership. Cocoindex binds row-level upserts only.
  2. All existing FKs + CHECK + triggers + RLS continue to enforce — no “engine bypasses constraints” failure mode. PG rejections surface as asyncpg.PostgresError.
  3. content_text_hash GENERATED ALWAYS survives untouched — engine writes only columns declared in its TableSchema; omit GENERATED cols from the declaration.
  4. pgvector vector(1024) compatible_vector_encoder produces standard text format; KH’s vector(1024) columns work directly.

Phase 2 first-step checklist (per 0.9-spike-S1.md §8) — to be inherited by T8 first dispatch:

  1. Create Supabase branch cocoindex-spike from staging (Liam authorised S230-start).
  2. Run the prepared harness at spike/cocoindex_s1/probe_managed_by_user.py against the spike branch to verify schema bytes unchanged + trigger fired + GENERATED auto-computed + CHECK violation raises.
  3. Document empirical results as §9 addendum to the spike report.

Findings absorbed into T8 acceptance criteria (per PLAN.md §4.8): “Spike #1 (cocoindex schema-coupling) resolves cleanly OR per 02-data-flow.md §3.1 (Scenario A confirmed).” — Scenario A already confirmed; T8 inherits Phase 2 first-step harness verification as Subtask #1.

§4.2 Spike #2 — cocoindex external-folder source binding (memo-hit)

Section titled “§4.2 Spike #2 — cocoindex external-folder source binding (memo-hit)”

Source: docs/plans/phase-0-investigation/0.9-spike-plan.md §2 (docs/plans/phase-0-investigation/0.9-spike-S2-cocoindex-folder-binding.md — full spike report).

Status:CLOSED-S229 — localfs-only v1; native fs-watch via watchfiles 1.1.1 for UC10. Memo-hit pattern fully validated against the 35-file canonical corpus (docs/client-documentation-corpus/):

  • Run A (cold cache): 35 invocations as predicted.
  • Run B (warm cache, no changes): 0 invocations — full memo hit confirmed.
  • Run C (after 4 UC4.4 mutations): 4 invocations — Δ semantics correct.

Δ semantics observations:

  • Add / edit / binary-replace → engine invokes @coco.fn once (cache populated/updated).
  • Delete → engine purges target state, does NOT invoke @coco.fn.
  • Rename → presents as add+delete pair (no native rename primitive — S188 surface concern shifts to UI layer).
  • Binary-replace indistinguishable from edit at engine level (S188-feedback friction — product/UI work for “did user re-upload different doc?” sits downstream).

Findings absorbed elsewhere (NOT T8 scope):

  • DRAFT-vs-final dedup substrate — cocoindex CONFIRMED INSUFFICIENT (content_fingerprint is exact-bytes, all DRAFT-vs-final pairs in corpus have fully-distinct fingerprints). Routes to Spike #10 (0.9-spike-S10-dedup-substrate.md) → Task T12 (mempalace KG integration) / UC8 (T9). Not T8 scope.
  • LMDB single-writer concern routes to Spike #14 (0.9-spike-S14-cocoindex-concurrency.md) → CLOSED-S230 with single-orchestrator-instance topology (already absorbed into §2.2 above).
  • SharePoint connector gap → v1.1 deferral; v1 ships localfs only.

Findings absorbed into T8 acceptance criteria (per PLAN.md §4.8): “LocalFS source binding observes file change → pipeline run → content_items row written within polling cadence (integration test).” — memo-hit + Δ semantics already proven; T8 inherits the test corpus + the integration-test shape.

SpikeStatusRatificationT8 dependency
S1 (cocoindex schema-coupling)CLOSED-S230 — Scenario A confirmedSource-code conclusive (_target.py:1313-1315 + statediff.py:128)Phase 2 first-step harness verification at T8 Subtask #1 (inherited from S1 §8)
S2 (cocoindex external-folder source binding — memo-hit)CLOSED-S229 — localfs-only v1 + native fs-watch for UC10Cold 35 / warm 0 / mutated 4 invocation counts confirmed on 35-file canonical corpusT8 Subtask integration test inherits test corpus + invocation-count shape
S14 (cocoindex concurrency under LMDB single-writer)CLOSED-S230 — single-orchestrator-instance topology10 concurrent writers tested cleanly empiricallyT8 Cloud Run Service config: min_instances=1, max_instances=1 per S14 §3

All three relevant spikes for T8 are closed. No PENDING-SPIKE blocker.


§5. Single-spec vs multi-spec recommendation

Section titled “§5. Single-spec vs multi-spec recommendation”

§5.1 Decision context — existing spec landscape

Section titled “§5.1 Decision context — existing spec landscape”

The docs/specs/ tree already contains spec directories that directly absorb scope that a naive “T8 = one spec” framing would otherwise duplicate:

Existing specScope it ownsDrafting Task per PLAN.md
docs/specs/id-36-cocoindex-extraction-contract/{PRODUCT,TECH}.mdQ-EX2 Pydantic discriminated-union contract for ExtractByLlm outputs (Q&A vs entity vs classification). Validation rules + class shapes.T1.1 + T1.2 (Wave 0 — already gating-spec)
docs/specs/id-36-cocoindex-ledger-api/TECH.mdTS-facing API over cocoindex per-flow-run ledger. DEFERRED-v1.1 per RATIFIED-S243 Item 11 — v1 ships only pipeline_runs rollup.T1.3 (DEFERRED-v1.1; existing draft retained as v1.1 substrate)
docs/specs/id-56-content-model-invariants/Q1.3-Q1.N content-model invariants (per-row shape post-ingest, chunking-boundary rules).T1.4

This means T8 RESEARCH does NOT recommend authoring its own extraction-contract / ledger-API / content-model-invariants sub-spec — those are landed elsewhere by T1. T8’s actual PRODUCT + TECH scope is the flow scaffolding, sidecar deployment, and op_id propagation glue that consumes (a) the T1 specs and (b) the schema landed by T2

  • T6.

§5.2 Candidate split — what a multi-spec arrangement would look like

Section titled “§5.2 Candidate split — what a multi-spec arrangement would look like”

A naive split surfaces three candidate sub-specs:

  • cocoindex-flow — pipeline topology (6-stage chain per 02-data-flow.md §3.1), source-binding adapter shape, layered fn-shape contract, @coco.fn outer/inner patterns, integration with ExtractByLlm from cocoindex-extraction-contract.
  • cloud-run-sidecar — Cloud Run Service vs Job topology, container image build (Docling + cocoindex + pullmd boundary), per-tenant manifest expansion, LMDB ephemeral-storage shape per S14, model-pre-warm container layer.
  • extractor-functions — the actual per-MIME extractor @coco.fn wrappers (Docling for PDF/DOCX/XLSX, pullmd for HTML), LiteLLMEmbedder integration, layered fn-shape binding into the flow.

§5.3 Recommendation — single spec (cocoindex-flow-scaffolding)

Section titled “§5.3 Recommendation — single spec (cocoindex-flow-scaffolding)”

Recommendation: T8 lands as ONE cocoindex-flow-scaffolding/{PRODUCT,TECH}.md pair, NOT split into three.

Reasoning:

  1. Scope coherence — the three candidate splits are tightly coupled at the implementation layer. The flow topology (cocoindex-flow) cannot exist without the sidecar runtime (cloud-run-sidecar); the extractor functions (extractor-functions) only have a home INSIDE the flow topology. Splitting creates three specs that all mutually reference each other on every Behavior invariant — a triple-coupled spec set is harder to ratify than a single coherent spec.
  2. Subtask 25-soft-ceiling (PRODUCT inv 20) — a back-of-envelope Subtask count for T8 sits at ~10-12 implementation slices (PLAN.md §4.8 lists 11 Subtasks: Spike #1 verification, Cloud Run sidecar config, flow.py scaffolding, outer source-binding adapter, inner extraction fns, ExtractByLlm integration, LiteLLMEmbedder, postgres.mount_table_target mounts, op_id propagation, integration test, CocoInsight deferral note). Well within the 25-Subtask soft ceiling for a single Task — no need to split for Subtask-count headroom.
  3. No genuine independent ratification surface — splitting would require Liam to separately ratify three specs that share every cross-cutting invariant (idempotency, memo-hit, layered fn-shape, op_id propagation, RLS interaction). A single spec ratifies once.
  4. Aligned with existing precedent — sibling PRODUCT+TECH pairs at docs/specs/id-38-rls-pattern/, docs/specs/reserved-workspace-seats/, docs/specs/intelligence-workspaces/, docs/specs/id-59-concurrent-edit-intent-arbitration/ all bundle scope at the same coupling-density T8 sits at. T8 is not anomalously large vs these.
  5. Spec slug stabilitycocoindex-flow-scaffolding accurately describes the substantive scope (flow scaffolding + sidecar deploy as ONE work-stream). Renaming later if implementation surfaces a genuine split-warranting split is cheap; landing a single spec now is the lower-risk path.

Recommended spec slug: cocoindex-flow-scaffolding (the provisional slug — KEEP AS-IS). Drop the multi-spec rename consideration.

PRODUCT.md Behavior invariants candidates (for {28.2} Planner to refine):

  1. Cocoindex flow scaffolding observes a file change in a tracked source-binding location → pipeline run executes → content_items row written within polling cadence.
  2. Memo-hit: re-running cocoindex over the same file content does NOT duplicate q_a_extractions rows (per @coco.fn(memo=True) + content-hash idempotency per S2).
  3. Layered fn-shape: inner-tier extraction @coco.fn functions consume content_text: str (NOT FileLike) per COCO.10.
  4. Per-flow op_id propagates into content_items.op_id + q_a_extractions.op_id + source_documents.op_id columns per N7 hybrid pattern.
  5. audit_log row written for cocoindex-driven inserts/updates per trigger pattern (independent of cocoindex’s op_id).
  6. Cloud Run Service for cocoindex engine deploys via existing .github/workflows/cloud-run-deploy.yml infra (no new CI workflow).
  7. MCP eval Layer 4 (functional correctness): create_content_item over MCP results in queryable content within same call (per PLAN.md §4.8 acceptance criterion).
  8. (Operational) Re-extraction cycle short-circuits at @coco.fn(memo=True) when content-hash matches the stored hash for the same source-key.

TECH.md Proposed changes per invariant candidates (one-to-one with PRODUCT invariants — for {28.3} fresh Planner to author):

  1. scripts/cocoindex_pipeline/flow.py scaffolding per 02-data-flow.md §3.
  2. @coco.fn(memo=True) outer + inner per parse-flow.py stub pattern.
  3. Per-MIME @coco.fn wrappers around Docling (PDF/DOCX/XLSX) + pullmd (HTML).
  4. postgres.mount_table_target(managed_by=ManagedBy.USER) mounts for content_items, source_documents, q_a_extractions.
  5. op_id ColumnDef(type="uuid", nullable=True) in each TableSchema.
  6. Cloud Run Service manifest at cloudrun/jobs/{prod,staging}-{kpf,phew}-cocoindex.yaml with min_instances=1, max_instances=1 per S14.
  7. requirements.txt additions: cocoindex[postgres]>=1.0.3, docling, litellm (verify exact pin).
  8. Integration test under __tests__/integration/cocoindex/ writing file to test folder, polling, asserting content_items row appears with correct shape + embedding + op_id.

Each open question carries (a) the source citation that surfaces it, (b) the recommended default, (c) the cost-of-deferral.

IDQuestionSourceRecommended defaultCost of deferral
O-Q1Does source_documents.op_id column exist after T2 (Q-OQR1-16) apply? If not, does T8 add the ALTER as a T8-internal DDL slice, or is it routed to a T2 follow-up?02-data-flow.md:135 — “potentially other cocoindex target tables” leaves the column’s home un-pinned. T2’s combined-PR scope per PLAN.md §4.2 names content_items.op_id only.T8 absorbs the ALTER as an internal slice (add op_id to source_documents + q_a_extractions if missing) per the canonical-pipeline T2 follow-up pattern. Verify via pre-T8 \d source_documents + \d q_a_extractions against prod.LOW — verifiable via 5-min schema inspection pre-T8 dispatch.
O-Q2Cloud Run Service vs Job split for v1: cocoindex engine as a long-running Service (continuous fs-watch + LMDB) OR scheduled Job (re-fingerprint corpus on each cron tick)? Per S14, both are viable; the choice affects per-tenant deploy manifest shape.0.9-spike-S14-cocoindex-concurrency.md:610 — “v1 Cloud Run cocoindex job: min_instances=1, max_instances=1 OR scheduled Cloud Run job.”Service (continuous fs-watch matches the live=True source-binding contract; cocoindex’s LMDB warm-cache amortises across runs; better UX for “edit in IDE → see in KH” feedback loop). Scheduled Job is a fallback if Service cost proves prohibitive.MEDIUM — informs TECH.md sidecar topology + per-tenant manifest expansion.
O-Q3pullmd co-location: keep pullmd as a separate self-hosted Cloud Run service that the cocoindex sidecar calls via HTTP (preserving the AGPL “network-service” boundary), OR co-locate in the cocoindex sidecar image (simpler ops but tighter license-surface coupling)?03-tech-stack.md:166-168 — AGPL “network-service” clause analysis hinges on separate process.Keep separate — preserves the license boundary per 03-tech-stack.md §7.3 analysis. Co-location risks AGPL propagation onto KH platform code.LOW (legally) — separation is the cheap-and-safe default.
O-Q4Container image size budget after Docling + cocoindex additions: target ~5.3 GB vs current 3.3 GB. Acceptable per Cloud Build limits (16 GB image-size cap; per cloudrun/cloudbuild.yaml); but cold-start latency hit per phase-b-prerequisite-2d-docling-bakeoff.md §6 ~44.75 s first-call.cloudrun/cloudbuild.yaml + phase-b-prerequisite-2d-docling-bakeoff.md §6.Pre-warm Docling model in the container image layer; accept the +1.8 GB; mitigate cold-start via min_instances=1 (Service keeps one warm). Cost-projection sub-task at T13.LOW — verifiable post-build.
O-Q5LiteLLMEmbedder("openai/text-embedding-3-large") per 02-data-flow.md §3.1 stage 4 — litellm is net-new dependency in requirements.txt. Pin version + verify Anthropic / OpenAI passthrough auth works inside Cloud Run (WIF service account; not direct API key).02-data-flow.md:61 + requirements.txt (cocoindex absent, litellm absent).Pin litellm>=1.x (lookup latest stable in TECH.md authoring); inject Anthropic / OpenAI API keys via Cloud Run secret-manager binding per existing pattern.LOW — standard dependency-pin task.
O-Q6Pre-warm strategy for cocoindex’s LMDB ops-DB on Cloud Run cold-start: re-fingerprint corpus from scratch on every container restart (~7 s for 35 files, scales linearly), OR mount Cloud Storage persistent volume for LMDB persistence (latency hit + complexity)?0.9-spike-S14-cocoindex-concurrency.md §6.1.Ephemeral re-fingerprint per S14 default (no Cloud Storage mount; v1 corpus scale is small enough). Re-evaluate at v1.1 if corpus crosses 10k files.LOW — S14 already ratified the default.
O-Q7CocoInsight on-prem deployment — T8 PRODUCT inv coverage: include CocoInsight as DEFERRED note OR omit entirely? Per 02-data-flow.md §5.4 + 03-tech-stack.md §5.6, CocoInsight is engineering-pipeline-observability (separate audience from audit_log); on-prem posture is STILL-OPEN, only relevant if KH self-hosts.02-data-flow.md:138-144; 03-tech-stack.md:108-110.Include as DEFERRED-v1.1 note in PRODUCT.md (single-line — “CocoInsight is engineering observability surface; v1 ships without; re-evaluate if self-host scenario surfaces”).LOW — note-only.
O-Q8T7 Phew Q&A first-ingest interaction: does T8 ship with the cocoindex source-binding pointed at docs/client-documentation-base/qa-library/ (or equivalent Phew path) from day one, OR does T8 ship empty source-binding and T7 stages the Phew files post-T8 stable?docs/specs/id-31-canonical-pipeline-implementation-plan/PLAN.md §4.7 (T7 dependency on T8).T8 ships empty source-binding (Service running, fs-watch armed); T7 stages Phew files into the watched location per its own subtask. Decouples cutover.LOW — sequencing decision; either order works.

§7.1 Primary sources (read in full for this RESEARCH)

Section titled “§7.1 Primary sources (read in full for this RESEARCH)”
  • docs/specs/id-31-canonical-pipeline-implementation-plan/PLAN.md — §4.8 T8 detail (acceptance criteria + subtasks + deps + risks); §3 dependency graph (T8 critical-path position); §4.5 T5 closure note (parallel-track precedent for SHIPPED-marker); §4.7 T7 (downstream dependent on T8).
  • docs/plans/phase-0-investigation/architecture/02-data-flow.md — §1-§3 (cocoindex 6- stage topology) + §4 (Cloud Run sidecar) + §5 (op_id hybrid + N7) + §7.2 (recordPipelineRun() rollup) + §10 (anti-patterns).
  • docs/plans/phase-0-investigation/architecture/03-tech-stack.md — §3 Vercel + Next.js (function-bundle constraint); §4 Cloud Run sidecar; §5 cocoindex (recurring runtime); §6 Docling; §7 pullmd; §13 retired alternatives; §14 anti-patterns.
  • docs/plans/phase-0-investigation/0.9-decision-graph.md — §11.4.1 (N7 op_id ratification surfaced via citations in 02-data-flow.md:117 and 09-diagrams.md:50,148,397).
  • docs/plans/phase-0-investigation/0.9-spike-plan.md — §1 S1 scope; §2 S2 scope.
  • docs/plans/phase-0-investigation/0.9-spike-S1-cocoindex-schema-coupling.md — full spike report (CLOSED-S230 Scenario A).
  • docs/plans/phase-0-investigation/0.9-spike-S2-cocoindex-folder-binding.md — full spike report (CLOSED-S229 localfs + memo-hit).
  • docs/plans/phase-0-investigation/0.9-spike-S14-cocoindex-concurrency.md — full spike report (CLOSED-S230 single-orchestrator topology).
  • CLAUDE.mdcocoindex 1.0.3 Gotcha; Supabase & Schema; Deployment.
  • .github/workflows/cloud-run-deploy.yml — existing Cloud Run deploy workflow.
  • docs/runbooks/cloud-run-phase-1.md — Cloud Run Phase 1 runbook (note: brief referenced cloud-run-phase-1-handover.md — actual filename is cloud-run-phase-1.md, no handover suffix).
  • cloudrun/cloudbuild.yaml + cloudrun/jobs/{prod,staging}-{kpf,phew}.yaml — existing per-tenant Cloud Run manifest convention.
  • scripts/ontology-sync/parse-flow.py — canonical layered fn-shape stub.
  • spike/cocoindex_s1/probe_managed_by_user.py — canonical live-wiring shape (S230 harness).

§7.2 Secondary sources (consulted, cited inline)

Section titled “§7.2 Secondary sources (consulted, cited inline)”
  • docs/plans/phase-0-investigation/architecture/09-diagrams.md (ERD + op_id banner references — confirms content_items.op_id ERD render is post-Q-OQR1-16 shape).
  • docs/plans/phase-0-investigation/phase-b-prerequisite-2-cocoindex-deep-dive.md (cocoindex affordance synthesis; cited via 02-data-flow.md references).
  • docs/plans/phase-0-investigation/phase-b-prerequisite-2d-docling-bakeoff.md (Docling bake-off evidence; 1.8 GB footprint + cold-start latency).
  • docs/specs/id-36-cocoindex-extraction-contract/ (Q-EX2 contract — T1.1+T1.2; NOT T8 scope per §5.1 analysis).
  • docs/specs/id-36-cocoindex-ledger-api/TECH.md (TS-facing ledger API — DEFERRED-v1.1; NOT T8 scope).
  • docs/specs/id-56-content-model-invariants/ (Q1.3-Q1.N content-model — T1.4; NOT T8 scope).

Required area (from dispatch brief)RESEARCH.md sectionStatus
1. cocoindex 1.0.3 integration constraints§1.1-§1.6✅ Covered (sandbox-disable; walk_dir(recursive=True); layered fn-shape; existing examples; API drift; sample call shapes)
2. Cloud Run sidecar deployment shape§2.1-§2.3✅ Covered (Docling 1.8 GB + pullmd 3.7 GB sizing; existing infra baseline; gaps inventory; assessment)
3. op_id propagation contract§3.1-§3.3✅ Covered (N7 hybrid; trigger-driven rationale; column-level + trigger-level surface inventory)
4. Spike #1 + Spike #2 status§4.1-§4.3✅ Covered (S1 CLOSED-S230 Scenario A; S2 CLOSED-S229 memo-hit + localfs; +S14 absorbed)
5. Single-spec vs multi-spec decision§5.1-§5.4✅ Covered — RECOMMEND SINGLE SPEC at slug cocoindex-flow-scaffolding

8 open questions surfaced in §6 (O-Q1 through O-Q8) — all recommended-default ratifiable in Liam pre-flight before {28.2} PRODUCT dispatch.


End of RESEARCH.md. Next: Liam ratification gate (single-spec recommendation + 8 open questions) → {28.2} PRODUCT.md authoring by fresh task-planner per Q-PLANNER-2.