File size: 17,619 Bytes
0e1d1f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a3bf2ca
 
 
 
 
0e1d1f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a3bf2ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0e1d1f6
 
 
 
 
 
 
a3bf2ca
 
 
 
 
 
 
 
 
23f1015
a3bf2ca
 
0e1d1f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a3bf2ca
 
0e1d1f6
 
a3bf2ca
 
0e1d1f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
-- 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;