provinans / db /schema.sql
reversely's picture
Upload db/schema.sql with huggingface_hub
23f1015 verified
Raw
History Blame Contribute Delete
17.6 kB
-- 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;