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); yieldI 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 / claim | Method | Result |
|---|---|---|---|
| R1.1 | cocoindex.flow, cocoindex.DataSlice, .transform, collect, export, add_collector, flow_def, FlowBuilder | hasattr on cocoindex module + __all__ enumeration | ABSENT — 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.2 | bind_target on any DataSlice | n/a — no DataSlice type exists | ABSENT (fiction confirmed). |
| R1.3 | postgres.TableTarget.declare_row(*, row: RowT) | inspect.signature on cocoindex.connectors.postgres._target.py:1164 | PRESENT. def declare_row(self, *, row: RowT) -> None. Row = dict / dataclass / NamedTuple / Pydantic; must include all PK columns. Internally calls coco.declare_target_state(...). |
| R1.4 | postgres.mount_table_target(db, table, schema, *, pg_schema_name, managed_by) async | inspect.signature, _target.py:1332 | PRESENT, async. Returns ready-to-use TableTarget. |
| R1.5 | ManagedBy.USER semantics | cocoindex.connectorkits.target.ManagedBy; _target.py:1314 docstring | PRESENT. USER = "user" ⇒ “table must exist, CocoIndex only manages rows” (no DDL). SYSTEM = "system" ⇒ CocoIndex creates/drops the table. KH wants USER. |
| R1.6 | TableTarget.declare_vector_index(*, column, metric, method, …) (Stage-4 embedding hook) | inspect.signature, _target.py:1194 | PRESENT. Native pgvector-index declaration; metric ∈ {cosine,l2,ip}, method ∈ {ivfflat,hnsw}. Index named {table}__vector__{name}. |
| R1.7 | coco.use_context(key) arity | inspect.signature → (key: ContextKey[T]) -> T | SINGLE-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.8 | coco.App(AppConfig(...), main_fn) shape | App.__init__ accepts `str | AppConfigfirst-positional +main_fnsecond;AppConfig` fields = {name, environment, max_inflight_components} |
| R1.9 | cocoindex.functions.ExtractByLlm / LlmSpec / LlmApiType | grep installed tree | ABSENT — confirms docs/research/cocoindex-1.0.3-extractbyllm-spec-reality-investigation.md (S256). KH Path A (direct anthropic in @coco.fn) is correct. |
| R1.10 | cocoindex.ops native helpers | import + dir() | ops.text → RecursiveSplitter, SeparatorSplitter (chunking, no extra dep). ops.litellm → LiteLLMEmbedder/LiteLLMTranscriber (needs litellm, present in requirements.txt; embedding/transcription only — no LLM extraction). ops.sentence_transformers → SentenceTransformerEmbedder. ops.entity_resolution → resolve_entities / LLM-pair resolver (needs faiss). |
| R1.11 | mount_each(fn, items, *args) per-item-component semantics | read api.py:445-529 + live probe | CONFIRMED. 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.12 | localfs.walk_dir(path, *, live, recursive).items() feed shape | _source.py:147-155 + live probe; File attrs | CONFIRMED. .items() yields (relative_path: str, File); live=True → LiveMapView (fs-watch). File exposes file_path, read(), read_text(), content_fingerprint, size — NOT .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.fndef 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 inputasync 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 provisioningdef _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_idis just a row field. Sourced fromFLOW_META_CTX(already populated bybind_flow_meta()inflow_context.py, which survives re-grounding unchanged). Nobind_target(op_id=), noflow["op_id"].managed_by=ManagedBy.USER⇒ cocoindex writes rows only, never DDL. KH migrations own the schema.content_text_hashisGENERATED ALWAYS— must stay OMITTED from theTableSchemacolumns (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-flowuse_context(key, value).
§R3. Live end-to-end probe (PROVEN)
Section titled “§R3. Live end-to-end probe (PROVEN)”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-endThis 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 aaaaop_id_intended=bbbbbbbb rows_op_id=['aaaaaaaa', 'aaaaaaaa'] # run 2 — row op_id STAYS aaaaThe 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(needslitellm, in requirements.txt) orops.sentence_transformers.SentenceTransformerEmbedder; (b) KH calls its own embedder insideingest_fileand writes thevector(1024)into theembeddingcolumn viadeclare_row. Theembeddingcolumn already exists inCONTENT_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 NULLembeddingcannot serve vector search. But it is a within-ingest_filetransform + onedeclare_rowcolumn + 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_entitiesexists but needsfaiss(NOT in requirements.txt today).entity_mentionsextraction (the EXTRACT step) is Path A and feeds theq_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 theTODO(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:
- The entire reactive flow in
app_main()(flow.py:671-765) is fictional dataflow API and must be rewritten to themount_each+ per-item-@coco.fn+declare_rowshape. The transforms (convert_binary_to_markdown, the three extractors) move from flow-scope.transform()chaining INTO the per-itemingest_filebody. coco.use_context(DB_CTX, coco_pool)as an async-CM (flow.py:670) is wrong-arity; DB pool provisioning moves to a@coco.lifespanenv builder usingbuilder.provide(...). This touches howserver.py/__main__.pyboot the App (they must register the lifespan on the environment).flow_context.py(FLOW_META_CTX /bind_flow_meta/bind_retry_counter/current_flow_meta) survives unchanged — it is a stdlibcontextvarssubstrate with no fictional API. The op_id CONSUMPTION site moves from the (fictional)bind_target(op_id=)to the row dict insideingest_file.- The
TableSchemadeclarations (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 (directanthropic.AsyncAnthropicinside@coco.fn), so cocoindex’s LLM retry does NOT cover KH’s extraction calls. - KH’s own tenacity wrapper (
_anthropic_retryinextraction.py, with thebefore_sleephook bumping thebind_retry_counterflow-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 callback — TableTarget._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”§1.1 Runtime sandbox + LMDB cold-start
Section titled “§1.1 Runtime sandbox + LMDB cold-start”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.mdGotchas → “cocoindex 1.0.3 requiresdangerouslyDisableSandbox: truefor both PyPI install and Rust-engine LMDB startup in dev.localfs.walk_dirdefaultsrecursive=False— explicitrecursive=Trueneeded 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]'withdangerouslyDisableSandbox: truerequired 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-128surfaceswalk_dirdefaultingrecursive=False; explicitrecursive=Truerequired for nested folder corpora. Phew’s Q&A markdown corpus is nested (T7 dependency), so T8’s source-binding adapter must setrecursive=True.02-data-flow.md:32— cocoindex binds vialocalfs.walk_dir(live=True)(LocalFS or Cloud Run mounted share) emitting file-change events. Layer-2 admin-metadata binding perphase-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 viawatchfiles 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 acceptcontent_text: str(notFileLike) 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-104— the canonical KH stub for the layered fn-shape (file-tier outer fnprocess_ontology_file(file: FileLike) -> ParsedCV; inner fnsparse_cv_frontmatter(content_text: str)andvalidate_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:
| File | Role |
|---|---|
scripts/ontology-sync/parse-flow.py | Canonical 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.py | S230 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:
sources→connectorsrename — oldfrom cocoindex.sources import localfsis stale; the harness usesfrom cocoindex.connectors.postgres import ...but the spike doc also notes aconnectorsnamespace path.@flow_def→App(name, main_fn)— flow-definition pattern is now App-class based. Reference shape:coco.start(coco.AppConfig(name="...", main_fn=app_main))perprobe_managed_by_user.py:246.- 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=1OR 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. 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 cocofrom 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. Cloud Run sidecar deployment shape
Section titled “§2. Cloud Run sidecar deployment shape”§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: truerequired 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”).
§2.2 Existing Cloud Run baseline + gaps
Section titled “§2.2 Existing Cloud Run baseline + gaps”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 atcloudrun/jobs/{prod,staging}-{kpf,phew}.yaml. Cloudbuild pipeline atcloudrun/cloudbuild.yaml(single image variantkh-pipeline, ~3.3 GB, default entrypointpython3 scripts/ingest.py). - CI/CD wiring:
.github/workflows/cloud-run-deploy.ymltriggers on push tomain(→kh-prod-494815) orproduction-readiness(→kh-staging-494815); paths guarded toscripts/**/*.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):
- cocoindex not in
requirements.txt— net-new dependency for the sidecar. Will pullcocoindex[postgres]+ transitive deps (asyncpg+ LMDB native). Pre-flight sizing impact: ~50-150 MB. - Docling not in
requirements.txt— net-new 1.8 GB binary + model download (cold start ~44.75 s perphase-b-prerequisite-2d-docling-bakeoff.md§6; mitigated by@coco.fn(memo=True)+ Docling-model pre-warm in container image). - 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 per03-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. - 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 onmin_instances=1, max_instances=1Cloud 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). - 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.
- Per-tenant manifest expansion — the existing 4 manifests
(
{prod,staging}-{kpf,phew}.yaml) cover the legacyscripts/ingest.pypipeline. 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).
§2.3 Assessment
Section titled “§2.3 Assessment”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.
§3. op_id propagation contract
Section titled “§3. op_id propagation contract”§3.1 Hybrid pattern (N7 RESOLVED-S236)
Section titled “§3.1 Hybrid pattern (N7 RESOLVED-S236)”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 stableop_idper pipeline run and propagates it into the Postgres target columns it writes. Everycontent_itemsrow, everyq_a_extractionsrow, everysource_documentsstate update written by a given cocoindex run carries that run’sop_id. Pipeline-side correlation (“which rows did run X write?”) is answerable directly from the target tables. - Trigger-driven
audit_log— KH’saudit_logtable captures platform-wide audit events via PostgresAFTER INSERT / UPDATE / DELETEtriggers 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-flowop_idalone cannot supply (e.g. direct UI edits have no cocoindexop_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:
- 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(). - 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”| Surface | Where it lands | Owner / status | T8 dependency |
|---|---|---|---|
content_items.op_id column (uuid, NULLABLE) | 04-workspace-types.md schema + Q-OQR1-16 combined-PR migration per 02-data-flow.md:135 | T2 (combined-PR) — RATIFIED-S236; column-level shape STILL-OPEN pending Q-OQR1-16 migration | T8 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 column | T6 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 column | Likely 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 DDL | T8 open question — see §6 O-Q1 |
audit_log table schema | Platform-level governance table — NOT part of Q-OQR1-16 combined-PR scope per 02-data-flow.md:135-136 | Governed 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_documents | Already 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 separately | Inherited from existing schema; cocoindex INSERT ... ON CONFLICT DO UPDATE writes DO fire all existing triggers | T8 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. Spike #1 + Spike #2 status
Section titled “§4. Spike #1 + Spike #2 status”§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-1315docstring — “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:128—resolve_system_transitionreturnsNonewhenevert.desired.managed_by == "user", causing thediff()step to skip every DDL action (CREATE / DROP / ALTER all unreachable).
Implications for T8 (0.9-spike-S1.md §4):
- No schema redesign required —
content_items,content_chunks,source_documents,q_a_extractions,entity_mentions,entity_relationshipsall stay under KH ownership. Cocoindex binds row-level upserts only. - All existing FKs + CHECK + triggers + RLS continue to enforce — no
“engine bypasses constraints” failure mode. PG rejections surface as
asyncpg.PostgresError. content_text_hash GENERATED ALWAYSsurvives untouched — engine writes only columns declared in itsTableSchema; omit GENERATED cols from the declaration.- pgvector
vector(1024)compatible —_vector_encoderproduces standard text format; KH’svector(1024)columns work directly.
Phase 2 first-step checklist (per 0.9-spike-S1.md §8) — to be inherited by T8
first dispatch:
- Create Supabase branch
cocoindex-spikefromstaging(Liam authorised S230-start). - Run the prepared harness at
spike/cocoindex_s1/probe_managed_by_user.pyagainst the spike branch to verify schema bytes unchanged + trigger fired + GENERATED auto-computed + CHECK violation raises. - 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.fnonce (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_fingerprintis 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.
§4.3 Status summary
Section titled “§4.3 Status summary”| Spike | Status | Ratification | T8 dependency |
|---|---|---|---|
| S1 (cocoindex schema-coupling) | CLOSED-S230 — Scenario A confirmed | Source-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 UC10 | Cold 35 / warm 0 / mutated 4 invocation counts confirmed on 35-file canonical corpus | T8 Subtask integration test inherits test corpus + invocation-count shape |
| S14 (cocoindex concurrency under LMDB single-writer) | CLOSED-S230 — single-orchestrator-instance topology | 10 concurrent writers tested cleanly empirically | T8 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 spec | Scope it owns | Drafting Task per PLAN.md |
|---|---|---|
docs/specs/id-36-cocoindex-extraction-contract/{PRODUCT,TECH}.md | Q-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.md | TS-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 per02-data-flow.md§3.1), source-binding adapter shape, layered fn-shape contract,@coco.fnouter/inner patterns, integration withExtractByLlmfrom 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.fnwrappers (Docling for PDF/DOCX/XLSX, pullmd for HTML),LiteLLMEmbedderintegration, 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:
- 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. - 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.pyscaffolding, outer source-binding adapter, inner extraction fns,ExtractByLlmintegration,LiteLLMEmbedder,postgres.mount_table_targetmounts,op_idpropagation, integration test, CocoInsight deferral note). Well within the 25-Subtask soft ceiling for a single Task — no need to split for Subtask-count headroom. - 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.
- 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. - Spec slug stability —
cocoindex-flow-scaffoldingaccurately 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.
§5.4 What the single spec must cover
Section titled “§5.4 What the single spec must cover”PRODUCT.md Behavior invariants candidates (for {28.2} Planner to refine):
- Cocoindex flow scaffolding observes a file change in a tracked source-binding
location → pipeline run executes →
content_itemsrow written within polling cadence. - Memo-hit: re-running cocoindex over the same file content does NOT duplicate
q_a_extractionsrows (per@coco.fn(memo=True)+ content-hash idempotency per S2). - Layered fn-shape: inner-tier extraction
@coco.fnfunctions consumecontent_text: str(NOTFileLike) per COCO.10. - Per-flow
op_idpropagates intocontent_items.op_id+q_a_extractions.op_id+source_documents.op_idcolumns per N7 hybrid pattern. audit_logrow written for cocoindex-driven inserts/updates per trigger pattern (independent of cocoindex’sop_id).- Cloud Run Service for cocoindex engine deploys via existing
.github/workflows/cloud-run-deploy.ymlinfra (no new CI workflow). - MCP eval Layer 4 (functional correctness):
create_content_itemover MCP results in queryable content within same call (per PLAN.md §4.8 acceptance criterion). - (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):
scripts/cocoindex_pipeline/flow.pyscaffolding per02-data-flow.md§3.@coco.fn(memo=True)outer + inner perparse-flow.pystub pattern.- Per-MIME
@coco.fnwrappers around Docling (PDF/DOCX/XLSX) + pullmd (HTML). postgres.mount_table_target(managed_by=ManagedBy.USER)mounts forcontent_items,source_documents,q_a_extractions.op_idColumnDef(type="uuid", nullable=True)in each TableSchema.- Cloud Run Service manifest at
cloudrun/jobs/{prod,staging}-{kpf,phew}-cocoindex.yamlwithmin_instances=1, max_instances=1per S14. requirements.txtadditions:cocoindex[postgres]>=1.0.3,docling,litellm(verify exact pin).- Integration test under
__tests__/integration/cocoindex/writing file to test folder, polling, assertingcontent_itemsrow appears with correct shape + embedding +op_id.
§6. Open questions for Liam ratification
Section titled “§6. Open questions for Liam ratification”Each open question carries (a) the source citation that surfaces it, (b) the recommended default, (c) the cost-of-deferral.
| ID | Question | Source | Recommended default | Cost of deferral |
|---|---|---|---|---|
| O-Q1 | Does 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-Q2 | Cloud 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-Q3 | pullmd 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-Q4 | Container 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-Q5 | LiteLLMEmbedder("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-Q6 | Pre-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-Q7 | CocoInsight 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-Q8 | T7 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. References
Section titled “§7. References”§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 in02-data-flow.md:117and09-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.md—cocoindex 1.0.3Gotcha; 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 referencedcloud-run-phase-1-handover.md— actual filename iscloud-run-phase-1.md, nohandoversuffix).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 — confirmscontent_items.op_idERD render is post-Q-OQR1-16 shape).docs/plans/phase-0-investigation/phase-b-prerequisite-2-cocoindex-deep-dive.md(cocoindex affordance synthesis; cited via02-data-flow.mdreferences).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).
§8. Coverage matrix
Section titled “§8. Coverage matrix”| Required area (from dispatch brief) | RESEARCH.md section | Status |
|---|---|---|
| 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.