Download docs/knowledge/KNOWLEDGE_PERSISTENCE_CONTRACT.md from DataEyond/Agentic-Service-Data-Eyond-Catalog: direct link, hf CLI and curl.
- Browser
- Download file 30.3 kB
-
https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog/resolve/refs%2Fpr%2F21/docs/knowledge/KNOWLEDGE_PERSISTENCE_CONTRACT.md
- Command line
-
hf download hf://spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog@refs/pr/21/docs/knowledge/KNOWLEDGE_PERSISTENCE_CONTRACT.md
-
curl -L -o KNOWLEDGE_PERSISTENCE_CONTRACT.md https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog/resolve/refs%2Fpr%2F21/docs/knowledge/KNOWLEDGE_PERSISTENCE_CONTRACT.md
Knowledge Pipeline β Persistence Contract (DDL handoff)
β LIVE β the five tables exist in dedorch as of 2026-09-07. Agreed by Rifqi and created by a one-time manual DDL run, exactly as knowledge_tables.sql. Still owed: Harry folds that SQL into Go's migration set β until he does, the tables exist only because they were run by hand, and a fresh environment comes up without them. That is the same gap that still leaves
message_traceabilityandmessage_chartsout of the migrations (Β§7).π‘ Was: PROPOSAL β v2, revised 2026-09-07, sent to Harry 2026-09-01. This is the D2 deliverable: the tables the knowledge pipeline needs, in Harry-ready form. Nothing here exists in dedorch yet and no ORM model has been written against it β do not implement until Harry confirms. Go owns the dedorch schema (CLAUDE.md Β§2.2); Python executes no DDL, ever.
Revision 2026-09-07 β read this before the tables below. Three changes: the first two driven by a read-only introspection of the live dedorch database, the third a decision by Rifqi.
Q1 is CLOSED.
documents.idistext NOT NULL PRIMARY KEYβ verified in the live schema and confirmed by Rifqi.knowledge_documents.document_idistextand may carry a real FK.A fifth table,
knowledge_jobs. Ingestion is asynchronous (parse ~80 s + extract ~170 s is far past any HTTP timeout) and Data Eyond's flow is one click over all of a user's documents, so the job is a batch that outlives any single document. Parsing has no run row, so a parse-stage failure had nowhere to live.
scope_idβ company scope, decided by Rifqi 2026-09-07. Knowledge is a company's, not a user's: Data Eyond is sold to a company whose employees are its users, so the production standard one employee uploads and the maintenance SOP another uploads are one body of knowledge and must share a glossary. Under auser_idkey they would never diff against each other and dedup would never fire. Every table here therefore carriesscope_id text NOT NULLbesideuser_id(which stays, as the uploader).The value is provisional, and that is safe only because of one invariant. dedorch has no companies table (verified β 15 tables, none a tenant entity) and
users.companyis nullable free text with no id. Soscope_idis populated today from a normalisedusers.company(casefold + trim), falling back touser:<user_id>when it is null β which degrades cleanly to single-user scope rather than to a shared bucket. Swapping to a realcompanies.idlater is then anUPDATE β¦ SET scope_id, a value migration, provided:β οΈ
scope_idMUST NEVER enter theentity_idderivation.ids.pyhashesdoc_id+ content and nothing else (verified 2026-09-07). Keep it that way andscope_idstays a pure row-level partition key that can be re-pointed freely. Fold it into the hash and re-pointing it becomes a re-key that destroys every expert approval β the exact failure the whole id scheme exists to prevent.
scope_idis also a tenant boundary, not just a label. Every read filters on it. This is the F-1 lesson (DEV_PLAN #38), where a store filtered on one key while Go filtered on two and the ownership check ended up comparing a value against itself.Known weaknesses of the interim value, accepted knowingly: a typo in
users.companyforks a company's knowledge base, and renaming the company orphans it. Both are fixed by Q6.Still deferred: a canonical
knowledge_conceptslayer unifying the same term across documents. Company scope makes it more likely to be wanted β two employees' documents genuinely collide, where one user's rarely would β but resolving a collision needs an expert to rule, and Data Eyond's flow has none. Until thenknowledge_entries.diff_statuscarries the cross-document relationship, classified and surfaced rather than collapsed. See context doc Β§1.6.
Sent: 2026-09-01 Β· Owner: Rifqi β Harry Β· Tracker: DEV_PLAN Β§0.8 D2 Β· checklist X17
Producers: src/knowledge_parsing/ (artifact) Β· src/knowledge_extraction/ (runs, entries)
Consumers: Go review CRUD (read + reviews) Β· Python analytics path (approved entries, later)
Companion to the two output contracts, which define the payload shapes this schema stores: KNOWLEDGE_PARSING_OUTPUT_CONTRACT.md (parsing) and KNOWLEDGE_OUTPUT_CONTRACT.md (extraction). This document adds only where those payloads live, never their shape.
0 Β· Ownership
| Concern | Owner | Note |
|---|---|---|
| The DDL + migration | Harry (Go) | Go owns every dedorch migration. Python runs no create_all |
| Writing rows | Python | Parse + extract insert; transitional, same posture as report_inputs / message_charts |
| Review CRUD (read entries, approve/edit/reject) | Harry (Go) | Records CRUD is Go's shape. Python is generation-only (CLAUDE.md Β§2.4) |
Ingestion endpoint (POST β¦/knowledge/ingest) |
Python | The pipeline lives here. Async batch job. (Was: "only the Python image carries MinerU" β MinerU dropped 2026-09-07; auth posture settled separately, surface plan Β§6) |
1 Β· Why five tables
The set was cut from five (chunks folded into the artifact JSONB, four entity kinds folded into one
knowledge_entries), then knowledge_jobs was added back for a different reason. Three of the five
look droppable and are not:
knowledge_extraction_runs. One document is re-extracted many times as prompts are tuned.
Without a run row, entries from two runs are indistinguishable and a quality change cannot be
attributed. This is not hypothetical: identical input scored 6/7 one day and 0/12 the next, and
CallUsage.model_version / system_fingerprint exist precisely because the deployment name stayed
stable while the model behind it moved. A run without those recorded is a measurement of an unknown.
knowledge_reviews. The candidate entry is what the model claimed; the review is what the expert
ruled. They are different facts with different authors and must not share a row. Collapsing them
into a status column on the entry destroys the audit trail that the entire span-verification
apparatus exists to produce β and makes an edited value overwrite the claim it was correcting.
knowledge_jobs. A run row is created only once parsing has succeeded and extraction begins, so
without a job row a parse failure is unrepresentable: the caller polls for something that will never
exist. It is also the only row that spans the whole batch β Data Eyond's flow is one click over all
of a user's documents, so the unit the user is waiting on is N documents, not one. knowledge_reviews
is the one table Data Eyond's flow never writes; it is here because the expert loop is what the
experiment exists to learn about (context doc Β§1.5).
2 Β· The tables
-- 1. Parsed document artifact. Chunks live INSIDE `artifact` (jsonb), deliberately
-- not a child table: documents are small (9 pages = 31 chunks) and the artifact
-- is versioned as a unit.
CREATE TABLE knowledge_documents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
doc_id text NOT NULL, -- pipeline key, e.g. 'STD_2026_006_MNO' (see 5 Q1)
document_id text REFERENCES documents(id), -- Q1 CLOSED 2026-09-07: documents.id is text PK.
-- Null for local CLI parses; set when ingested from an uploaded doc.
scope_id text NOT NULL, -- TENANT KEY: the company. Provisional value -
-- normalised users.company, else 'user:<user_id>'. See revision note 3.
user_id text NOT NULL, -- the uploader, not the owner of the knowledge
source_path text NOT NULL,
content_hash text NOT NULL, -- sha256 of the SOURCE FILE
n_pages integer NOT NULL,
version integer NOT NULL DEFAULT 1,
schema_version text NOT NULL, -- ParsedDocument contract version, e.g. '0.3.1'
parser_name text NOT NULL DEFAULT 'mistral', -- Mistral OCR is the primary parser since 2026-09-02
parser_version text, -- '3.4.4'
parser_backend text, -- 'mistral' | 'pipeline' | 'hybrid' | 'vlm' (see section 3)
parser_config text, -- fingerprint of output-affecting settings
normaliser_version text, -- fingerprint of our post-processing code
raw_output_dir text, -- blob key for the untouched parser output
artifact jsonb NOT NULL, -- full ParsedDocument, chunks[] included
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (doc_id, content_hash, version)
);
CREATE INDEX idx_knowledge_documents_scope ON knowledge_documents (scope_id, created_at DESC);
CREATE INDEX idx_knowledge_documents_user ON knowledge_documents (user_id);
-- 2. One row per extraction run.
CREATE TABLE knowledge_extraction_runs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
knowledge_document_id uuid NOT NULL REFERENCES knowledge_documents(id) ON DELETE CASCADE,
doc_id text NOT NULL,
scope_id text NOT NULL, -- tenant key, see revision note 3
user_id text NOT NULL,
status text NOT NULL DEFAULT 'running', -- running | succeeded | failed
branches jsonb NOT NULL DEFAULT '[]', -- ['glossary','rule','formula','summary']
model_deployment text, -- OUR name for it; stable
model_version text, -- returned by the API; CAN move under a stable deployment
system_fingerprint text,
n_calls integer NOT NULL DEFAULT 0,
prompt_tokens integer NOT NULL DEFAULT 0,
cached_tokens integer NOT NULL DEFAULT 0,
completion_tokens integer NOT NULL DEFAULT 0,
usage jsonb NOT NULL DEFAULT '[]', -- CallUsage[]
rejected jsonb NOT NULL DEFAULT '[]', -- RejectedField[] - what the span check refused
counts jsonb NOT NULL DEFAULT '{}', -- cluster/entry counts, link dangle counts
error_message text,
created_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz
);
CREATE INDEX idx_knowledge_runs_doc ON knowledge_extraction_runs (knowledge_document_id);
-- 3. Candidate entries - all four kinds in one table.
CREATE TABLE knowledge_entries (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
run_id uuid NOT NULL REFERENCES knowledge_extraction_runs(id) ON DELETE CASCADE,
knowledge_document_id uuid NOT NULL REFERENCES knowledge_documents(id) ON DELETE CASCADE,
doc_id text NOT NULL,
scope_id text NOT NULL, -- tenant key; every read filters on it
user_id text NOT NULL,
kind text NOT NULL CHECK (kind IN ('glossary','rule','formula','domain')),
entity_id text NOT NULL, -- term_id | rule_id | formula_id | domain_id
label text, -- term / formula name / rule head, for listing without opening jsonb
mention_count integer NOT NULL DEFAULT 0,
extraction_status text, -- ok | no_definition_found | escalated
diff_status text, -- new | duplicate | conflicting
definition_conflict boolean NOT NULL DEFAULT false,
payload jsonb NOT NULL, -- full GlossaryEntry | RuleEntry | FormulaEntry | DomainContext
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (run_id, kind, entity_id)
);
CREATE INDEX idx_knowledge_entries_scope ON knowledge_entries (scope_id, kind);
CREATE INDEX idx_knowledge_entries_doc_kind ON knowledge_entries (doc_id, kind);
CREATE INDEX idx_knowledge_entries_entity ON knowledge_entries (entity_id);
CREATE INDEX idx_knowledge_entries_queue
ON knowledge_entries (run_id, definition_conflict DESC, mention_count DESC);
-- 4. Expert decisions. APPEND-ONLY - no UPDATE, no DELETE.
-- A changed mind is a NEW row; current state = latest row per entity_id.
CREATE TABLE knowledge_reviews (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
entry_id uuid NOT NULL REFERENCES knowledge_entries(id) ON DELETE RESTRICT,
-- RESTRICT, not CASCADE: a cascade would let entry pruning silently delete an
-- expert's rulings, destroying the audit trail the entity_id keying protects. See 3.5.
entity_id text NOT NULL, -- the DURABLE key: survives re-runs, unlike entry_id
doc_id text NOT NULL,
scope_id text NOT NULL, -- tenant key, see revision note 3
content_hash text NOT NULL, -- WHICH artifact version this decision was made against
decision text NOT NULL CHECK (decision IN ('approved','edited','rejected')),
edited_payload jsonb, -- populated only when decision = 'edited'
note text,
reviewer_id text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_knowledge_reviews_entity ON knowledge_reviews (entity_id, content_hash);
CREATE INDEX idx_knowledge_reviews_entry ON knowledge_reviews (entry_id);
-- 5. Ingestion jobs. ASYNC ENVELOPE, and the batch unit: Data Eyond's flow is
-- one click over MANY documents, so one job spans N of them. Whether the
-- batch is the clicker's documents or the whole company's is open (surface plan).
-- A run row exists only once extraction starts, so a PARSE failure has no
-- other home; without this table the caller polls for a row never created.
CREATE TABLE knowledge_jobs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scope_id text NOT NULL, -- tenant key, see revision note 3
user_id text NOT NULL, -- who clicked the button
status text NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued','running','succeeded','partial','failed','blocked')),
-- 'blocked' = the pre-flight estimate exceeded the spend ceiling and the job
-- declined to continue. NOT a failure: it is the one outcome an operator can
-- act on (raise the ceiling, or ingest fewer documents at a time).
stage text CHECK (stage IN ('parsing','filtering','estimating','extracting','persisting')),
-- Ordered by cost. Everything up to and including 'estimating' is free or
-- near-free; 'extracting' is where the LLM spend happens, which is why the
-- ceiling is enforced between them.
document_ids jsonb NOT NULL DEFAULT '[]', -- documents(id)[] requested in this batch
n_requested integer NOT NULL DEFAULT 0,
n_succeeded integer NOT NULL DEFAULT 0,
n_failed integer NOT NULL DEFAULT 0,
results jsonb NOT NULL DEFAULT '[]', -- per-document {document_id, knowledge_document_id,
-- run_id, status, error} - what the poll route renders
estimated_cost jsonb, -- pre-flight estimate_cost() output; the spend gate
error_message text, -- set only when the JOB failed, not a member document
created_at timestamptz NOT NULL DEFAULT now(),
started_at timestamptz,
completed_at timestamptz
);
CREATE INDEX idx_knowledge_jobs_user ON knowledge_jobs (user_id, created_at DESC);
3 Β· Column semantics that are not obvious
entity_id is deterministic and content-derived β t_/f_/r_/b_ plus a sha256 prefix over
document-scoped content, never positional and never model-written (src/knowledge_extraction/ids.py;
scheme in KNOWLEDGE_OUTPUT_CONTRACT.md Β§1). This is what lets an
expert's approvals survive a prompt retune, which is why knowledge_reviews keys on
entity_id + content_hash rather than on entry_id: re-extracting mints new entry rows but keeps
the same entity ids, so prior decisions still resolve. These two tables together are the persisted
registry that the "known limit" note in ids.py and the output contract both point at.
parser_backend is not cosmetic. MinerU's CPU-only pipeline backend copies the multiplication
sign as a literal letter x, fusing MOHH x Qty x PA into a single token and taking five glossary
terms with it; hybrid/vlm write \times. It also never analyses images, so
generated_description is always null. A pipeline artifact is valid but lower fidelity and must
never be used as a reference measurement (calibration Β§13). Recording the backend per row is what
keeps a CPU-parsed artifact distinguishable from a GPU-parsed one after the fact.
normaliser_version and parser_config fingerprint different things. parser_config covers
MinerU's settings; normaliser_version covers our own post-processing code; schema_version
versions the artifact's shape. None of the three moves when a normalisation bug is fixed without a
shape change β which is exactly how a stale artifact went unnoticed for a day on 2026-08-26.
The review queue is not stored. It is a pure sort over entries β conflicts first, then descending
mention count (queue/review_queue.py). Persisting a derived ordering creates a second truth that
drifts from the first. idx_knowledge_entries_queue exists so Go can produce it with an ORDER BY;
review_reason is likewise derived at read time from diff_status / definition_conflict /
extraction_status / the presence of a definition.
Promoted columns are a read optimisation, not a second copy. mention_count,
extraction_status, diff_status and definition_conflict also live inside payload. They are
lifted out so the queue can be ordered and filtered in SQL without opening jsonb. payload is the
source of truth; a writer must set both from the same object in the same insert.
Files stay files. The source PDF and the untouched MinerU output belong in blob storage, not
Postgres. raw_output_dir holds a blob key, not a local path β today it is a local directory, which
is a Python-side change tracked in KNOWLEDGE_SURFACE_PLAN.md.
3.5 Β· Conformance with dedorch's live conventions (checked 2026-09-07)
Checked by read-only introspection so Harry gets nothing to bounce back. Matching already:
uuid primary keys with DEFAULT gen_random_uuid() (pgcrypto is installed), text for user_id
and document ids, timestamptz β¦ DEFAULT now(), jsonb NOT NULL DEFAULT '[]'::jsonb / '{}'::jsonb,
plural snake_case table names, idx_<table>_<purpose> index names, Postgres-default <table>_<col>_fkey
constraint names.
One deliberate divergence, flagged rather than hidden. dedorch's existing foreign keys carry no
ON DELETE action (message_charts_analysis_id_fkey is a bare REFERENCES analyses(id)), while the
knowledge tables use ON DELETE CASCADE down the document β run β entry chain. That is intended:
those three are one artifact at three grains, and an entry outliving the run that produced it is
meaningless. Say the word if the house rule is no cascade anywhere and we will make deletion explicit
instead.
β οΈ One cascade is wrong and should not ship as drafted:
knowledge_reviews.entry_id β¦ ON DELETE CASCADE. Reviews are the append-only record of what a human ruled, and they key onentity_id+content_hashprecisely so a decision survives re-extraction β but a cascade means pruning old entry rows silently deletes the approvals attached to them. The keying protects the audit trail and the cascade destroys it. RecommendON DELETE RESTRICT: an entry that has been ruled on cannot be deleted until its reviews are dealt with deliberately. This is the one change to the DDL between v2 as drafted and what should actually be migrated.
documents FK β keep it (A4, decided 2026-09-07). knowledge_documents.document_id text REFERENCES documents(id). documents.id is a stable text primary key, ingestion now reads
documents rows rather than file paths, and it is the natural stable key for the doc_id problem on
the parsing side. Null stays legal for local CLI parses that never went through an upload.
4 Β· Write rules for Python
- No DDL, ever. Python only INSERTs and SELECTs. If a column is missing, the read is
getattr-tolerant of both shapes and a new handoff goes to Harry. knowledge_reviewsis append-only. Python never UPDATEs or DELETEs it. Neither should Go.knowledge_documentsis content-addressed. Re-parsing the same file with the same settings should find the existing(doc_id, content_hash, version)row, not duplicate it. A changed document or changed parser settings is a newversion.- Entries are written per run, never upserted across runs. Two runs of one document produce two
full sets of entry rows sharing
entity_ids. History is the point. - Ingest is never on the request path. Nothing in the chat pipeline imports the parsing or
extraction modules; the span model is imported lazily so
import mainpulls neither torch nor gliner. That stays true.
5 Β· Open questions for Harry
Verified against the live dedorch database 2026-09-07 (read-only introspection: 15 tables). The questions the schema itself could answer have been answered; what is left needs Harry.
Q1 β CLOSED 2026-09-07. document_id type.documents.id is text NOT NULL PRIMARY KEY
(live schema; pgcrypto is installed so gen_random_uuid() is available). knowledge_documents.document_id
is declared text REFERENCES documents(id). Confirm the FK is welcome, or say so and we drop the
constraint and keep the bare column.
Q2 β Review CRUD: yours later, ours minimally now. The four routes proposed on 2026-09-01 are the
production review surface and belong to the separate B2B knowledge product (context doc Β§1.5) β no
need to build them yet. But knowledge_reviews is written from day one, not left empty: we are
running the loop end to end in Data Eyond specifically to learn where it is wrong, and a pipeline that
stops at "here are 63 candidates" never surfaces the problems that appear only when a human works the
queue. Python will therefore expose a minimal, explicitly transitional review surface (list the
queue, record a decision) to be handed to Go when Product B is real. Flagging it so the eventual
ownership is not a surprise. Two shape amendments to carry whenever the production version is built: queue rows carry
row_kind (term | key_parameter, where a key_parameter row has null term_id, no definition
and no mention count), and provenance carries a 1-based page_no beside the 0-based page β
a review UI must render page_no.
Q3 β Provenance lookup cost. With chunks inside artifact, resolving one chunk_id to highlight a
span is a jsonb path query rather than an indexed row read. Fine at current sizes (9 pages = 31 chunks);
if a review UI ever hammers it, promoting chunks to their own table is the fix and is backward
compatible, so deferring costs nothing.
Q4 β Migration ordering. These five tables depend on no Go-owned table except the optional
documents FK in Q1, so the migration can land independently of anything else in flight.
Q5 β Are message_traceability and message_charts in your migration set? Both tables exist in
dedorch (verified: 80 and 6 rows) and idx_message_charts_message is present β but they were created
by hand from this side (DEV_PLAN #32 / V5 / V8) and were never handed over as a migration. A fresh
environment built from Go's migrations alone would therefore come up without them, and both write
paths are never-throw seams, so the failure is silent: charts and traceability simply stop being
recorded. Please fold the existing shapes into the migration set. No change to either table is being
requested β only that the schema of record matches what is live.
Q6 β We need a real company / organisation entity. Is one planned? β¬οΈ Raised from FYI to a
request 2026-09-07. Knowledge in Data Eyond is scoped to the company, not the uploader: one
employee's production standard and another's maintenance SOP are one body of knowledge and must share
a glossary. Every table here now carries scope_id text NOT NULL.
There is nothing to key it on today β dedorch has no companies table, and users.company is nullable
free text with no id (all 9 live users carry the same string). So the interim value is a normalised
users.company, falling back to user:<user_id>. That works, with two known weaknesses: a typo
forks a company's knowledge base, and renaming the company orphans it.
The ask: a companies table with a stable id, and users.company_id referencing it. Not blocking
β we can ship on the interim value β but the sooner it exists, the smaller the backfill. It stays a
cheap UPDATE β¦ SET scope_id (a value migration, never a re-key) because scope_id is deliberately
excluded from the entity_id derivation; that exclusion is an invariant on our side, recorded in the
revision note above.
6 Β· Status
| Item | State |
|---|---|
| Tables created in dedorch | β 2026-09-07 β 5 tables + 10 indexes, verified by introspection (column counts 18/20/15/11/15, all FKs and CHECKs present) |
| Go migration | β¬ The one thing still owed. Harry adopts knowledge_tables.sql verbatim |
| ORM models Β· write path Β· endpoints | β
2026-09-07 β src/db/postgres/models.py, src/knowledge_ingest/, src/api/v1/knowledge.py |
| Verified end to end | β 88 entry rows persisted from the BUMA standard; idempotency and tenant scoping both checked |
| DDL drafted and sent (v1, four tables) | β 2026-09-01 |
Revised to v2 β Q1 closed, knowledge_jobs added, scope_id (company) added |
β 2026-09-07 |
Conformance pass Β· documents FK kept Β· reviews FK CASCADE β RESTRICT Β· Q5 appendix |
β 2026-09-07 |
status/stage CHECK constraints widened before creation (blocked, filtering, estimating) |
β 2026-09-07 β as drafted, the table would have rejected rows this service produces |
| Ready to send β one self-contained document, six questions | β 2026-09-07 |
| Verified against the live dedorch schema (read-only) | β 2026-09-07 |
| Harry's response | β¬ blocks everything below |
ORM models in src/db/postgres/models.py |
β¬ Deliberately not written β waiting on Harry |
| Python write path | β¬ Blocked on the above |
| Minimal review surface (Python, transitional) | β¬ Blocked on the write path β the loop must close end to end |
| Go review CRUD (production) | βΈοΈ Deferred β Product B (Q2) |
7 Β· Appendix β two tables already live but never handed over (Q5)
Not part of the knowledge pipeline. They are here because this is the one handoff going to Harry
and both have been outstanding since July (DEV_PLAN #32 / V5 / V8). No change is requested β the
SQL below is the live shape, transcribed from dedorch on 2026-09-07 (pg_get_constraintdef +
pg_indexes, read-only). The ask is only that Go's migration set matches what is already running.
Why it matters. Both were created by hand from the Python side to unblock a live test, and the
hand-over never happened. A fresh environment built from Go's migrations alone comes up without
them β and because both write paths are never-throw seams, nothing errors: charts and traceability
simply stop being recorded, and the first symptom is an empty GET /charts that nobody can explain.
This is exactly the failure mode the appendix exists to close.
-- LIVE in dedorch (80 rows on 2026-09-07). Python-owned; one JSONB row per chat message.
-- Note: the PK is message_id itself - there is no surrogate id, and no FK to analyses.
CREATE TABLE message_traceability (
message_id text NOT NULL,
analysis_id uuid NOT NULL,
user_id text NOT NULL,
intent text NOT NULL,
data jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT message_traceability_pkey PRIMARY KEY (message_id)
);
CREATE INDEX idx_message_traceability_analysis ON message_traceability (analysis_id);
-- LIVE in dedorch (6 rows on 2026-09-07). Python-owned; one row per rendered chart.
CREATE TABLE message_charts (
id uuid NOT NULL DEFAULT gen_random_uuid(),
message_id text NOT NULL,
analysis_id uuid NOT NULL,
user_id text NOT NULL,
record_id text,
chart_type text NOT NULL,
title text,
spec jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT message_charts_pkey PRIMARY KEY (id),
CONSTRAINT message_charts_analysis_id_fkey FOREIGN KEY (analysis_id) REFERENCES analyses(id)
);
CREATE INDEX idx_message_charts_lookup ON message_charts (analysis_id, message_id);
CREATE INDEX idx_message_charts_message ON message_charts (message_id);
idx_message_charts_message backs the 2026-07-14 lead decision that GET /charts looks up by
message_id alone; idx_message_charts_lookup predates it and is kept because GET /traceability
still takes both parameters. report_inputs is not listed here β its schema was handed over on
2026-07-22 (DEV_PLAN #22) and needs nothing.