| -- WS1.2 append-only substrate schema (Postgres). | |
| -- | |
| -- The three-layer core (document / report_version / annotation) plus the W3C PROV tables | |
| -- (activity / artifact / activity_input), the INSERT-only writer role, the mutation-blocking | |
| -- triggers, actor stamping via SET LOCAL app.actor, and the current / agreement views. | |
| -- | |
| -- Grounded in docs/pilot_architecture.md: column names track the current stores this | |
| -- replaces (cases, reports, observations, field_confirmations, llm_calls). This file is the target | |
| -- schema; WS1.3 wires it under Alembic and WS1.4 routes every write through a data-access layer. | |
| -- Not yet applied to a running database: the WS1 exit criteria (app runs on Postgres locally, | |
| -- UPDATE annotation fails a test, as-of returns pre-amendment state) gate that. | |
| BEGIN; | |
| -- --------------------------------------------------------------------------- | |
| -- Roles. app_writer is the only principal the app uses: it may INSERT into the | |
| -- append-only tables and SELECT the views, and nothing else. security_officer is | |
| -- the sole role allowed to DELETE, for the excision runbook (WS4.4). app_reader | |
| -- is the analyst's SELECT-only principal. | |
| -- --------------------------------------------------------------------------- | |
| DO $$ | |
| BEGIN | |
| IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'app_writer') THEN | |
| CREATE ROLE app_writer LOGIN; | |
| END IF; | |
| IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'app_reader') THEN | |
| CREATE ROLE app_reader LOGIN; | |
| END IF; | |
| IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'security_officer') THEN | |
| CREATE ROLE security_officer LOGIN; | |
| END IF; | |
| END | |
| $$; | |
| -- --------------------------------------------------------------------------- | |
| -- Actor stamping. Every write runs inside BEGIN; SET LOCAL app.actor = '...'; the | |
| -- BEFORE INSERT trigger copies that setting onto created_by and refuses a write that | |
| -- did not name an actor. Machine writes set app.actor = 'svc:extractor'; a human write | |
| -- sets it to the reviewer identity. | |
| -- --------------------------------------------------------------------------- | |
| CREATE OR REPLACE FUNCTION stamp_actor() RETURNS trigger AS $$ | |
| DECLARE | |
| actor text := current_setting('app.actor', true); | |
| BEGIN | |
| IF actor IS NULL OR actor = '' THEN | |
| RAISE EXCEPTION 'no app.actor set: every write must SET LOCAL app.actor first'; | |
| END IF; | |
| NEW.created_by := actor; | |
| RETURN NEW; | |
| END; | |
| $$ LANGUAGE plpgsql; | |
| -- --------------------------------------------------------------------------- | |
| -- Append-only enforcement. UPDATE and DELETE raise, except for security_officer, | |
| -- which excision uses under the ticketed runbook. INSERT is the only ordinary write. | |
| -- --------------------------------------------------------------------------- | |
| CREATE OR REPLACE FUNCTION block_mutation() RETURNS trigger AS $$ | |
| BEGIN | |
| IF current_user <> 'security_officer' THEN | |
| RAISE EXCEPTION 'append-only: % on % is not permitted', TG_OP, TG_TABLE_NAME; | |
| END IF; | |
| IF TG_OP = 'DELETE' THEN | |
| RETURN OLD; | |
| END IF; | |
| RETURN NEW; | |
| END; | |
| $$ LANGUAGE plpgsql; | |
| -- --------------------------------------------------------------------------- | |
| -- Core layer 1: document. One row per ingested source document, keyed by the | |
| -- sha256 of its bytes so a re-ingest dedupes (WS3). Replaces the case/patient anchor | |
| -- role of the current cases table; case_barcode groups a patient's documents. | |
| -- --------------------------------------------------------------------------- | |
| CREATE TABLE IF NOT EXISTS document ( | |
| document_id text PRIMARY KEY, -- sha256 of the source bytes | |
| case_barcode text NOT NULL, | |
| project_id text NOT NULL DEFAULT 'default', | |
| source text NOT NULL, -- upload | watch | pull | |
| filename text, | |
| content_sha256 text NOT NULL, | |
| ingested_at timestamptz NOT NULL DEFAULT now(), | |
| created_by text NOT NULL | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_document_case ON document (case_barcode); | |
| -- Core layer 2: report_version. A version of a report drawn from a document. An | |
| -- amendment appends a new row with supersedes set to the prior version and version_no | |
| -- incremented (WS1.5 wires amendments.py to write these; today nothing does). | |
| CREATE TABLE IF NOT EXISTS report_version ( | |
| report_version_id text PRIMARY KEY, | |
| document_id text NOT NULL REFERENCES document(document_id), | |
| case_barcode text NOT NULL, | |
| report_type text NOT NULL, | |
| institution text, | |
| report_date date, | |
| staging_edition text NOT NULL, | |
| supersedes text REFERENCES report_version(report_version_id), | |
| version_no integer NOT NULL DEFAULT 1, | |
| report_text_sha256 text, -- text stored as an artifact, referenced by hash | |
| is_authoritative boolean, | |
| created_at timestamptz NOT NULL DEFAULT now(), | |
| created_by text NOT NULL | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_rv_document ON report_version (document_id); | |
| CREATE INDEX IF NOT EXISTS idx_rv_supersedes ON report_version (supersedes); | |
| -- PROV: activity. One row per run (extraction, ocr, review, ingest, migration). | |
| -- Replaces llm_calls, which is already append-only; adds the kind and version fields | |
| -- the PROV graph needs. | |
| CREATE TABLE IF NOT EXISTS activity ( | |
| activity_id text PRIMARY KEY, | |
| kind text NOT NULL, -- extraction | ocr | review | ingest | migration | |
| model text, | |
| prompt_sha256 text, | |
| template_version text, | |
| params_json jsonb, | |
| input_tokens integer, | |
| output_tokens integer, | |
| latency_ms real, | |
| status text NOT NULL DEFAULT 'ok', | |
| started_at timestamptz NOT NULL DEFAULT now(), | |
| ended_at timestamptz, | |
| created_by text NOT NULL | |
| ); | |
| -- PROV: artifact. A hashed thing an activity produced or consumed: a structured | |
| -- extraction, a raw agent trace (PHI, stored as a blob_ref), or ocr_text. | |
| CREATE TABLE IF NOT EXISTS artifact ( | |
| artifact_id text PRIMARY KEY, | |
| kind text NOT NULL, -- extraction | agent_trace | ocr_text | report_text | |
| produced_by_act text REFERENCES activity(activity_id), | |
| content_sha256 text NOT NULL, | |
| blob_ref text, -- {data_root}/traces/{sha256}.json.zst for PHI blobs | |
| created_at timestamptz NOT NULL DEFAULT now(), | |
| created_by text NOT NULL | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_artifact_act ON artifact (produced_by_act); | |
| -- PROV: activity_input. The edge "activity used artifact as input" (an extraction | |
| -- reads an ocr_text or report_text artifact). New: no row-level link exists today. | |
| CREATE TABLE IF NOT EXISTS activity_input ( | |
| activity_id text NOT NULL REFERENCES activity(activity_id), | |
| artifact_id text NOT NULL REFERENCES artifact(artifact_id), | |
| role text, | |
| created_at timestamptz NOT NULL DEFAULT now(), | |
| created_by text NOT NULL, | |
| PRIMARY KEY (activity_id, artifact_id) | |
| ); | |
| -- Core layer 3: annotation. One assertion about a field on a report_version: a | |
| -- machine draft or a human correction. A correction appends a new row with supersedes | |
| -- pointing at the machine draft and version_no incremented, so the machine value and | |
| -- the human value both persist. produced_by_act links to the activity that made it. | |
| -- Replaces observations (machine) and field_confirmations (human), which today | |
| -- overwrite in place and share only a (case, field) key with no supersede edge. | |
| CREATE TABLE IF NOT EXISTS annotation ( | |
| annotation_id text PRIMARY KEY, | |
| report_version_id text REFERENCES report_version(report_version_id), | |
| case_barcode text NOT NULL, | |
| field_name text NOT NULL, | |
| value_json jsonb, | |
| value_kind text, -- number | category | text | boolean | |
| absence_reason text, -- the shared AbsenceCode vocabulary (CHECK below) | |
| absence_detail text, -- the human-readable reason, e.g. "FIGO grade on serous" | |
| absence_candidates jsonb, -- for a constrained field, the set the evidence narrowed to | |
| confidence real, | |
| evidence_json jsonb, -- page, bbox, quote, resolved to an input artifact by hash | |
| status text NOT NULL, -- needs_review | confirmed | flagged | not_applicable | |
| verification_status text, -- FHIR VerificationResult.status (CHECK below): a machine | |
| -- draft is attested; a human review is validated/val-fail/... | |
| supersedes text REFERENCES annotation(annotation_id), | |
| -- Which reader produced this value: a backend id from the settings selector (cloud_sonnet, | |
| -- cpu_batch, ...), 'visual' for a page located by ColPali, 'human' for a reviewer, 'migration' | |
| -- for a respelling. Two readers answering one field are two lineages, not one superseding the | |
| -- other, so a reviewer compares them instead of watching the second overwrite the first. | |
| method text, | |
| version_no integer NOT NULL DEFAULT 1, | |
| produced_by_act text REFERENCES activity(activity_id), | |
| created_at timestamptz NOT NULL DEFAULT now(), | |
| created_by text NOT NULL, -- svc:extractor for a draft, the reviewer for a correction | |
| CONSTRAINT absence_reason_vocab CHECK ( | |
| absence_reason IS NULL OR absence_reason IN | |
| ('not_applicable', 'not_stated', 'indeterminate', 'determined_by_join', 'constrained') | |
| ), | |
| -- FHIR VerificationResult status (http://hl7.org/fhir/CodeSystem/verificationresult-status): the | |
| -- coded outcome of the field's human verification. NULL only for a raw seed row. | |
| CONSTRAINT verification_status_vocab CHECK ( | |
| verification_status IS NULL OR verification_status IN | |
| ('attested', 'validated', 'in-process', 'req-revalid', 'val-fail', 'reval-fail', 'entered-in-error') | |
| ) | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_annotation_subject ON annotation (case_barcode, field_name); | |
| CREATE INDEX IF NOT EXISTS idx_annotation_supersedes ON annotation (supersedes); | |
| CREATE INDEX IF NOT EXISTS idx_annotation_act ON annotation (produced_by_act); | |
| -- --------------------------------------------------------------------------- | |
| -- Attach triggers: stamp the actor on insert, block update and delete. | |
| -- --------------------------------------------------------------------------- | |
| DO $$ | |
| DECLARE t text; | |
| BEGIN | |
| FOREACH t IN ARRAY ARRAY['document','report_version','activity','artifact','activity_input','annotation'] | |
| LOOP | |
| EXECUTE format('DROP TRIGGER IF EXISTS %I_stamp ON %I', t, t); | |
| EXECUTE format('CREATE TRIGGER %I_stamp BEFORE INSERT ON %I FOR EACH ROW EXECUTE FUNCTION stamp_actor()', t, t); | |
| EXECUTE format('DROP TRIGGER IF EXISTS %I_noupd ON %I', t, t); | |
| EXECUTE format('CREATE TRIGGER %I_noupd BEFORE UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION block_mutation()', t, t); | |
| EXECUTE format('DROP TRIGGER IF EXISTS %I_nodel ON %I', t, t); | |
| EXECUTE format('CREATE TRIGGER %I_nodel BEFORE DELETE ON %I FOR EACH ROW EXECUTE FUNCTION block_mutation()', t, t); | |
| END LOOP; | |
| END | |
| $$; | |
| -- --------------------------------------------------------------------------- | |
| -- Views. current is the live state: the newest non-superseded annotation per | |
| -- (case, field). agreement is the evaluation number: the rate at which a machine | |
| -- annotation was confirmed unchanged versus corrected by its human superseder, | |
| -- by field and by model and prompt version (WS2.6). | |
| -- --------------------------------------------------------------------------- | |
| -- One recognized region of one page, with the geometry the viewer draws and the identity evidence | |
| -- cites. Tesseract recomputed these on every call and stored none, so a value's box was resolved by | |
| -- re-reading the page and could not be cited, corrected, or checked. A region is addressed by | |
| -- `region_id`, which is stable across re-reads of the same page by the same engine. | |
| CREATE TABLE IF NOT EXISTS ocr_region ( | |
| region_id text PRIMARY KEY, | |
| document_id text NOT NULL, -- the scan this page belongs to | |
| case_barcode text NOT NULL, | |
| page_id text NOT NULL, -- stable page identity, document_id + page_no | |
| page_no integer NOT NULL, -- 1-based, the order the pages render in | |
| page_width integer NOT NULL, -- pixels of the rendered page the boxes are measured against | |
| page_height integer NOT NULL, | |
| ordinal integer NOT NULL, -- reading order within the page | |
| level text NOT NULL, -- word | line | paragraph | |
| text text NOT NULL, | |
| confidence real, -- the engine's own confidence, 0-100 for tesseract | |
| x0 real NOT NULL, -- normalized 0-1, so a viewer at any size scales it | |
| y0 real NOT NULL, | |
| x1 real NOT NULL, | |
| y1 real NOT NULL, | |
| polygon jsonb, -- for a deskewed or rotated page, where a box is not enough | |
| ocr_engine text NOT NULL, -- tesseract | |
| ocr_version text NOT NULL, -- the engine version, so a re-read under a new one is visible | |
| created_at timestamptz NOT NULL DEFAULT now(), | |
| CONSTRAINT ocr_region_level_vocab CHECK (level IN ('word', 'line', 'paragraph')), | |
| CONSTRAINT ocr_region_box_normalized CHECK ( | |
| x0 >= 0 AND y0 >= 0 AND x1 <= 1 AND y1 <= 1 AND x1 >= x0 AND y1 >= y0 | |
| ) | |
| ); | |
| CREATE INDEX IF NOT EXISTS ocr_region_page ON ocr_region (case_barcode, page_no, level, ordinal); | |
| CREATE INDEX IF NOT EXISTS ocr_region_page_id ON ocr_region (page_id); | |
| CREATE OR REPLACE VIEW current_annotation AS | |
| SELECT a.* | |
| FROM annotation a | |
| WHERE NOT EXISTS ( | |
| SELECT 1 FROM annotation s WHERE s.supersedes = a.annotation_id | |
| ); | |
| -- The tip of each reader's own chain. `current_annotation` answers "what fills the form"; this answers | |
| -- "what did each reader say", which is what a comparison needs. A row whose method is NULL predates the | |
| -- column and reads as its author's method. | |
| CREATE OR REPLACE VIEW current_annotation_by_method AS | |
| SELECT a.*, coalesce(a.method, CASE WHEN a.created_by = 'svc:extractor' THEN 'unrecorded' ELSE 'human' END) AS reader | |
| FROM annotation a | |
| WHERE NOT EXISTS ( | |
| SELECT 1 FROM annotation s | |
| WHERE s.supersedes = a.annotation_id | |
| AND coalesce(s.method, CASE WHEN s.created_by = 'svc:extractor' THEN 'unrecorded' ELSE 'human' END) = coalesce(a.method, CASE WHEN a.created_by = 'svc:extractor' THEN 'unrecorded' ELSE 'human' END) | |
| ); | |
| CREATE OR REPLACE VIEW agreement AS | |
| SELECT | |
| a.field_name, | |
| act.model, | |
| act.prompt_sha256, | |
| count(*) AS machine_values, | |
| count(*) FILTER (WHERE h.annotation_id IS NOT NULL) AS reviewed, | |
| count(*) FILTER (WHERE h.value_json IS NOT DISTINCT FROM a.value_json) AS agreed | |
| FROM annotation a | |
| JOIN activity act ON act.activity_id = a.produced_by_act AND act.kind = 'extraction' | |
| LEFT JOIN annotation h ON h.supersedes = a.annotation_id | |
| GROUP BY a.field_name, act.model, act.prompt_sha256; | |
| -- as_of(t): the state that was current at timestamp t. The newest annotation per | |
| -- (case, field) created at or before t whose superseder, if any, came after t. | |
| CREATE OR REPLACE FUNCTION as_of(t timestamptz) | |
| RETURNS SETOF annotation AS $$ | |
| SELECT a.* | |
| FROM annotation a | |
| WHERE a.created_at <= t | |
| AND NOT EXISTS ( | |
| SELECT 1 FROM annotation s | |
| WHERE s.supersedes = a.annotation_id AND s.created_at <= t | |
| ); | |
| $$ LANGUAGE sql STABLE; | |
| -- --------------------------------------------------------------------------- | |
| -- Grants. app_writer inserts and reads; app_reader reads the views only; | |
| -- security_officer may delete for excision. | |
| -- --------------------------------------------------------------------------- | |
| GRANT INSERT, SELECT ON document, report_version, activity, artifact, activity_input, annotation TO app_writer; | |
| GRANT SELECT ON current_annotation, current_annotation_by_method, agreement TO app_writer; | |
| GRANT SELECT, INSERT ON ocr_region TO app_writer; | |
| GRANT EXECUTE ON FUNCTION as_of(timestamptz) TO app_writer; | |
| GRANT SELECT ON current_annotation, current_annotation_by_method, agreement TO app_reader; | |
| GRANT SELECT ON ocr_region TO app_reader; | |
| GRANT EXECUTE ON FUNCTION as_of(timestamptz) TO app_reader; | |
| GRANT SELECT, DELETE ON document, report_version, activity, artifact, activity_input, annotation TO security_officer; | |
| -- --------------------------------------------------------------------------- | |
| -- Hash chain (verified-history variant, OFF by default). Adds a tamper-evident | |
| -- chain over annotation: each row hashes its content plus the prior row's hash. | |
| -- Enable only for the package variant that wants it (WS5.4), by adding the column | |
| -- and creating the trigger below. Left uncreated here so the default schema stays | |
| -- simple. | |
| -- | |
| -- ALTER TABLE annotation ADD COLUMN prev_hash text, ADD COLUMN row_hash text; | |
| -- CREATE OR REPLACE FUNCTION hash_chain() RETURNS trigger AS $chain$ | |
| -- DECLARE last_hash text; | |
| -- BEGIN | |
| -- SELECT row_hash INTO last_hash FROM annotation ORDER BY created_at DESC, annotation_id DESC LIMIT 1; | |
| -- NEW.prev_hash := last_hash; | |
| -- NEW.row_hash := encode(digest(coalesce(last_hash,'') || NEW.annotation_id || NEW.value_json::text, 'sha256'), 'hex'); | |
| -- RETURN NEW; | |
| -- END; | |
| -- $chain$ LANGUAGE plpgsql; | |
| -- CREATE TRIGGER annotation_chain BEFORE INSERT ON annotation FOR EACH ROW EXECUTE FUNCTION hash_chain(); | |
| COMMIT; | |