From 64cecf945e65394619a7fd9b618f6b6f64a14ca3 Mon Sep 17 00:00:00 2001
From: Joshua Simpson
Date: Thu, 5 Nov 2026 17:02:21 +0000
Subject: [PATCH 01/19] fix: harden audited access and data lifecycle paths
---
.github/workflows/sentry-alerts.yml | 6 +-
data/repo-awareness-snapshot.json | 2 +-
docs/audit/tenancy-defense-in-depth-review.md | 2 +-
docs/cme/design/cme-design-decisions.md | 18 +-
docs/codebase-index.md | 2 +-
docs/database-drift-detection.md | 27 +-
docs/disaster-recovery-runbook.md | 6 +
docs/error-tracking.md | 15 +
docs/operator-rehearsal-plan.md | 69 +-
docs/organisation/systems/delivery.json | 2 +
docs/organisation/systems/source-intake.json | 2 +
docs/worker-deploy-runbook.md | 14 +
scripts/archive/backfill-document-tags.ts | 50 +-
scripts/archive/backfill-enrichment.ts | 112 +-
scripts/backfill-visual-intelligence.ts | 122 +-
scripts/check-client-bundle-secrets.mjs | 12 +-
scripts/check-drift.ts | 2 +
scripts/enrich-documents.ts | 215 +-
scripts/import-documents.ts | 182 +-
scripts/lib/daily-health-summary.mjs | 17 +-
scripts/lib/import-document-queue.ts | 49 +
scripts/lib/tenancy-scan.mjs | 6 +-
scripts/reconcile-registry-governance.ts | 72 +-
scripts/verify-storage-restore.mjs | 203 +
src/app/api/cme/entries/[id]/route.ts | 2 +
.../api/on-call/entries/[id]/verify/route.ts | 27 +-
.../teaching/assessments/magic-links/route.ts | 10 +-
src/app/auth/confirm/route.ts | 24 +-
src/components/admin/admin-help-page.tsx | 24 +-
src/components/admin/admin-new-job-page.tsx | 26 +-
.../admin/admin-quick-add-sheet.tsx | 17 +-
src/components/admin/admin-renewals-page.tsx | 32 +-
src/components/admin/admin-renewed-sheet.tsx | 25 +-
src/components/admin/admin-setup-sheet.tsx | 22 +-
src/components/admin/admin-today-page.tsx | 18 +-
.../admin/contract/contract-end-page.tsx | 42 +-
src/components/admin/junior/junior-shared.tsx | 34 +-
.../admin/renewals/record-dates-sheet.tsx | 18 +-
.../admin/starter/starter-pack-page.tsx | 33 +-
.../clinical-ask-answer-surface.tsx | 10 +-
.../use-clinical-ask-runner.ts | 9 +-
src/components/cme/cme-entry-route-client.tsx | 30 +-
src/components/cme/cme-log-copy-sheet.tsx | 54 +-
src/components/cme/cme-log-page.tsx | 9 +-
src/components/on-call/call/call-page.tsx | 27 +-
src/components/on-call/on-call-check-page.tsx | 56 +-
.../on-call/on-call-entry-editor.tsx | 22 +-
.../on-call-entry-session-boundary.tsx | 29 +
.../on-call/on-call-section-page.tsx | 97 +-
.../on-call/on-call-verify-button.tsx | 42 +-
.../assessments/supervisor-link-panel.tsx | 61 +-
.../teaching/teaching-this-week.tsx | 12 +-
src/components/teaching/teaching-week.tsx | 12 +-
src/lib/calendar/feed-repository.ts | 40 +-
src/lib/clinical-ask-stream-contract.ts | 14 +-
src/lib/clinical-ask/client-stream.ts | 1 +
src/lib/clinical-ask/mode-profiles.ts | 7 +
src/lib/clinical-ask/orchestrator.ts | 23 +-
src/lib/clinical-ask/response-governance.ts | 7 +-
src/lib/cme/entry-version.ts | 20 +
src/lib/cme/load-cme-page-data.ts | 34 +-
src/lib/cme/repository.ts | 43 +-
src/lib/cme/schemas.ts | 24 +-
src/lib/cme/types.ts | 19 +
src/lib/cme/year-close.ts | 42 +-
src/lib/deep-memory.ts | 176 +-
src/lib/document-enrichment.ts | 59 +-
src/lib/env.ts | 4 +-
src/lib/ingestion-maintenance.ts | 121 +
src/lib/ingestion.ts | 13 +-
src/lib/on-call/api-schemas.ts | 4 +-
src/lib/on-call/entry-model.ts | 37 +
src/lib/on-call/entry-store.ts | 51 +-
src/lib/on-call/repository.ts | 4 +-
src/lib/openai.ts | 2 +
src/lib/roster/calendar-link-fetch.ts | 21 +-
src/lib/supabase/auth.ts | 25 +-
src/lib/supabase/database.types.ts | 54 +-
.../assessments/magic-link-repository.ts | 102 +-
.../assessments/supervisor-bookings.ts | 51 +-
src/lib/work-roles/people.ts | 32 +-
supabase/applied-migration-hashes.json | 11 +-
supabase/drift-manifest.json | 3858 ++++++++++++++++-
...t_private_evidence_lifecycle_and_drift.sql | 472 ++
...261105144246_audit_cpd_close_coherence.sql | 132 +
...0261105144248_audit_hospital_lifecycle.sql | 39 +
...4251_audit_atomic_document_maintenance.sql | 681 +++
...it_teaching_link_and_booking_lifecycle.sql | 91 +
...1105150056_audit_cpd_entry_concurrency.sql | 173 +
...3_audit_retire_unfenced_document_reset.sql | 9 +
supabase/schema.sql | 1618 +++++++
tests/admin-setup-sheet.dom.test.tsx | 25 +-
tests/admin-starter-ready-pages.dom.test.tsx | 17 +-
tests/auth-confirm-route.test.ts | 31 +-
tests/auth-live-recheck.test.ts | 45 +
tests/calendar-feed.test.ts | 36 +
tests/client-secret-surface.test.ts | 33 +
tests/clinical-ask-orchestrator.test.ts | 33 +
.../clinical-ask-response-governance.test.ts | 34 +
tests/clinical-ask-runner.dom.test.tsx | 56 +
tests/clinical-ask-stream-contract.test.ts | 30 +
tests/clinical-ask-web-budget.test.ts | 71 +
tests/clinical-ask-workspace.dom.test.tsx | 47 +-
tests/cme-api-contract.test.ts | 4 +-
tests/cme-archive-export.dom.test.tsx | 93 +
tests/cme-core-workflows.test.ts | 8 +-
tests/cme-entry-concurrency.test.ts | 135 +
tests/cme-log-copy-sheet.dom.test.tsx | 41 +
tests/cme-page-data.test.ts | 59 +
tests/cme-repository.test.ts | 7 +-
tests/cme-year-close.dom.test.tsx | 5 +-
tests/cme-year-close.test.ts | 117 +-
tests/daily-health-summary.test.ts | 67 +-
tests/deep-memory.test.ts | 55 +-
tests/document-enrichment.test.ts | 86 +-
tests/drift-detection.test.ts | 6 +-
tests/drift-private-inventory.test.ts | 79 +
tests/enrich-documents-guards.test.ts | 2 +-
tests/env.test.ts | 21 +
tests/epa-magic-link-repository.test.ts | 94 +-
tests/ingestion-import-queue.test.ts | 46 +
tests/ingestion-maintenance.test.ts | 171 +
tests/on-call-account-cache.dom.test.tsx | 118 +
tests/on-call-api-contract.test.ts | 6 +
tests/on-call-editor.dom.test.tsx | 73 +-
tests/on-call-entry-model.test.ts | 35 +
tests/on-call-entry-store.dom.test.tsx | 89 +
tests/on-call-entry-store.test.ts | 37 +-
tests/on-call-mutation-response.dom.test.tsx | 107 +
tests/on-call-repository.test.ts | 5 +
tests/on-call-shared-entry-publisher.test.ts | 71 +-
tests/on-call-tools.dom.test.tsx | 16 +-
tests/on-call-verify-all.dom.test.tsx | 150 +-
tests/production-readiness-offline.test.ts | 6 +-
...registry-governance-reconciliation.test.ts | 29 +
tests/roster-calendar-link.test.ts | 39 +-
.../sql/ingestion-maintenance-deep-memory.sql | 55 +
tests/sql/ingestion-maintenance.sql | 127 +
.../private-ingestion-document-deletion.sql | 238 +
tests/sql/private-ingestion-drift.sql | 73 +
tests/storage-restore-bytes.test.ts | 115 +
tests/teaching-dual-sign-off-durable.test.ts | 35 +
...ching-supervisor-link-history.dom.test.tsx | 89 +
...ork-bookings-concurrent-withdrawal.test.ts | 45 +-
tests/work-roles-people-changes.test.ts | 91 +
tests/worker-shadow-environment.test.ts | 151 +
worker/native/README.md | 14 +-
worker/shadow-extraction.ts | 83 +-
148 files changed, 12531 insertions(+), 1065 deletions(-)
create mode 100644 scripts/lib/import-document-queue.ts
create mode 100644 scripts/verify-storage-restore.mjs
create mode 100644 src/components/on-call/on-call-entry-session-boundary.tsx
create mode 100644 src/lib/cme/entry-version.ts
create mode 100644 src/lib/ingestion-maintenance.ts
create mode 100644 supabase/migrations/20261105144240_audit_private_evidence_lifecycle_and_drift.sql
create mode 100644 supabase/migrations/20261105144246_audit_cpd_close_coherence.sql
create mode 100644 supabase/migrations/20261105144248_audit_hospital_lifecycle.sql
create mode 100644 supabase/migrations/20261105144251_audit_atomic_document_maintenance.sql
create mode 100644 supabase/migrations/20261105144902_audit_teaching_link_and_booking_lifecycle.sql
create mode 100644 supabase/migrations/20261105150056_audit_cpd_entry_concurrency.sql
create mode 100644 supabase/migrations/20261105163543_audit_retire_unfenced_document_reset.sql
create mode 100644 tests/clinical-ask-web-budget.test.ts
create mode 100644 tests/cme-entry-concurrency.test.ts
create mode 100644 tests/drift-private-inventory.test.ts
create mode 100644 tests/ingestion-import-queue.test.ts
create mode 100644 tests/ingestion-maintenance.test.ts
create mode 100644 tests/on-call-account-cache.dom.test.tsx
create mode 100644 tests/on-call-mutation-response.dom.test.tsx
create mode 100644 tests/sql/ingestion-maintenance-deep-memory.sql
create mode 100644 tests/sql/ingestion-maintenance.sql
create mode 100644 tests/sql/private-ingestion-document-deletion.sql
create mode 100644 tests/sql/private-ingestion-drift.sql
create mode 100644 tests/storage-restore-bytes.test.ts
create mode 100644 tests/teaching-supervisor-link-history.dom.test.tsx
create mode 100644 tests/worker-shadow-environment.test.ts
diff --git a/.github/workflows/sentry-alerts.yml b/.github/workflows/sentry-alerts.yml
index 67faf96294..d17d0c0699 100644
--- a/.github/workflows/sentry-alerts.yml
+++ b/.github/workflows/sentry-alerts.yml
@@ -15,8 +15,8 @@ name: Sentry production alerts
# Setup (owner, once): a Sentry personal token with event:read, org:read and project:read,
# stored as the repository secret SENTRY_ALERTS_TOKEN. The organisation and project are
# discovered from the token; set the repository variables SENTRY_ORG / SENTRY_PROJECT only
-# if the account holds more than one of either. Without the secret every run is a warning
-# and nothing else.
+# if the account holds more than one of either. Without the secret the detector fails as
+# unavailable, so the daily summary cannot describe its silence as healthy.
on:
workflow_dispatch:
@@ -51,7 +51,7 @@ jobs:
script: |
const token = (process.env.SENTRY_ALERTS_TOKEN || "").trim();
if (!token) {
- core.warning("SENTRY_ALERTS_TOKEN is not set, so Sentry errors are not reaching the owner. See the header of sentry-alerts.yml.");
+ core.setFailed("SENTRY_ALERTS_TOKEN is not set; Sentry monitoring is unavailable.");
return;
}
diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json
index b89ac061e7..6bad9284b0 100644
--- a/data/repo-awareness-snapshot.json
+++ b/data/repo-awareness-snapshot.json
@@ -1,7 +1,7 @@
{
"version": "repo-awareness-snapshot-v3",
"captured_revision": {
- "committed_at": "2026-10-27"
+ "committed_at": "2026-11-01"
},
"routes": {
"modes": [
diff --git a/docs/audit/tenancy-defense-in-depth-review.md b/docs/audit/tenancy-defense-in-depth-review.md
index 68502aa14d..6931c96624 100644
--- a/docs/audit/tenancy-defense-in-depth-review.md
+++ b/docs/audit/tenancy-defense-in-depth-review.md
@@ -515,7 +515,7 @@ staying quiet.
| `src/lib/work-roles/hospital-sick.ts` | `work_hospital_teams` | `readHospitalSickCalls` | Team links of that same hospital, after the same gate (DB-01). |
| `src/lib/work-roles/hospital-starters.ts` | `work_hospitals` | `readHospitalStarters` | Hospital name after the `starters.view` gate (DB-01). |
| `src/lib/work-roles/hospital-starters.ts` | `work_hospital_teams` | `readHospitalStarters` | Team links of that same hospital, after the same gate (DB-01). |
-| `src/lib/work-roles/people.ts` | `work_hospitals` | `hospitalsFor`, `moveTeam`, `renameHospital`, `archiveHospital`, `applyPeopleChange` | Organisation structure (hospital id and name), no person named. Every write is administrator-only; `hospitalsFor` keeps only hospitals `canManagePeople` allows (DB-01). |
+| `src/lib/work-roles/people.ts` | `work_hospitals` | `hospitalsFor`, `moveTeam`, `renameHospital`, `archiveHospital`, `applyPeopleChange` | Organisation structure (hospital id and name), no person named. Every write is administrator-only; link-team additionally reads only its exact destination id and live archive state after that administrator check, repeated by the database guard under a row lock. `hospitalsFor` keeps only hospitals `canManagePeople` allows (DB-01). |
| `src/lib/work-roles/people.ts` | `work_hospital_teams` | `readPeople`, `readUnlinkedTeams`, `hospitalTeamIds`, `moveTeam`, `applyPeopleChange` | Team-to-hospital links, no person named. Reads follow the `canManagePeople` or administrator check; link and move are administrator-only (DB-01). |
| `src/lib/work-roles/server.ts` | `work_hospitals` | `addGrantedRoles` | Names and archive state of hospitals on the session user's own grants (`.eq("user_id", userId)`) (DB-01). |
| `src/lib/work-roles/server.ts` | `work_hospital_teams` | `addGrantedRoles` | Team links of those same hospitals, to resolve the session user's own roles (DB-01). |
diff --git a/docs/cme/design/cme-design-decisions.md b/docs/cme/design/cme-design-decisions.md
index 04be9347a8..ee58aebfff 100644
--- a/docs/cme/design/cme-design-decisions.md
+++ b/docs/cme/design/cme-design-decisions.md
@@ -381,16 +381,30 @@ Section 9's finalisation is now built in its smallest honest form. Closing is do
annual summary, in the last fortnight of the year or at any time afterwards, and asks first.
The database reads the year's confirmed targets, requirements and active activities under the
owner lock and freezes them as a snapshot, with the app's requirement statuses beside it; it
-refuses the close if those statuses were computed from a different total or activity count.
+refuses the close if any target, requirement or active-activity evaluation input changed after
+those statuses were computed. This includes category allocations, formal peer-review credit,
+practice-domain buckets and task completion. Direct requirement and allocation writers take the
+same year lock as closing, so the comparison and snapshot describe one coherent record.
An optional note explaining a shortfall is stored on the year and in the snapshot, and the
screen says that it does not reduce any requirement. A closed year cannot be reopened.
After closing, an activity is corrected with **Amend entry**, which takes the complete record
and a reason. The database stores the previous version, the new one and the reason, dated,
before applying the change, and refuses any other write to that year's activities,
-allocations or requirements. The snapshot never changes; the annual summary shows it, the note,
+allocations or requirements. Copying a closed activity's text remains available, but its recorded
+copied status cannot change through an ordinary update. The Log's copy sheet explains this and
+offers the next activity without a marking action. The snapshot never changes; the annual summary shows it, the note,
and every amendment, while its totals and the CSV export follow the amended record.
+Full manual edits and amendments carry the stored content the editor read. The database
+compares that content under its owner and entry locks before replacing fields or recording an
+amendment, and asks the owner to reload if another writer changed it. Allocations and the
+current copied/archive flags participate in that comparison; evidence and goal links do not,
+because the full editor does not replace them. This is current-content equality: changes that
+return to identical content are equivalent, rather than a monotonic mutation-history version.
+Teaching's intentional create/restore writer remains separate and owner-locked. Versioned RPCs
+make a missing migration fail closed; the migrations must precede the application release.
+
Not built: adding a new activity to a closed year, amending an archived activity, a separate
"as closed" export, carrying unfinished plan goals forward, and the evidence ZIP. The schema is
a new migration and so merges only with the owner's approval.
diff --git a/docs/codebase-index.md b/docs/codebase-index.md
index 535ba296d0..f7d3266636 100644
--- a/docs/codebase-index.md
+++ b/docs/codebase-index.md
@@ -1007,7 +1007,7 @@ Public-source control-plane tables: `public_source_policy_entries`, `public_sour
### Key RPCs
- **Jobs:** `admit_administrator_document` / `admit_backend_document` (trusted admission; the older `create_uploaded_document_with_ingestion_job` has no caller), `claim_ingestion_jobs`, `update_ingestion_job_progress` / `patch_ingestion_document_state` (attempt-fenced), `recover_ingestion_job`, `complete_ingestion_job`, `fail_or_retry_ingestion_job`, `complete_strict_enrichment_job`, `claim_indexing_v3_agent_jobs`
-- **Index lifecycle:** `reset_document_index`, `commit_document_index_generation`, `commit_document_deep_memory_generation`, `cleanup_abandoned_document_index_generations`
+- **Index lifecycle:** `reset_ingestion_attempt_artifacts` (attempt-fenced), `commit_document_index_generation`, `commit_document_deep_memory_generation` (worker fence overload), `commit_document_maintenance_deep_memory` (owner/version/generation snapshot), `cleanup_abandoned_document_index_generations`. The generation-blind `reset_document_index(uuid)` is retired.
- **Retrieval:** versioned families — v2 is owner-scoped, v3 corpus/governed. The default (legacy) path calls the v2 functions, falling back to v1 when a v2 function is missing: `match_document_chunks_hybrid_v2`, `match_document_chunks_text_v2`, `match_document_chunks_v2`, `match_document_lookup_chunks_text_v2`, `match_documents_for_query_v2`, `match_document_table_facts_text_v2`, `match_document_embedding_fields_hybrid_v2`, `match_document_index_units_hybrid_v2`, `match_document_memory_cards_hybrid_v2`, `search_document_chunks`; memory cards try `match_document_memory_cards_hybrid_v3` first and fall back to `_v2` (`deep-memory.ts`). The governed chunk family (`match_document_chunks_v3`, `match_document_chunks_hybrid_v3`, `match_document_chunks_text_v3`, backed by `match_governed_candidate_chunks_v3`) runs only when `RAG_GOVERNED_RETRIEVAL_ENABLED` is on; it is off by default and was off in production when checked on 2026-10-20. Rows are gated by `retrieval_owner_matches_v2` (fail-closed; `owner_id IS NULL` is public)
- **Health:** `search_schema_health`, `explain_retrieval_rpc`, `schema_drift_snapshot`, `migration_history_versions`
diff --git a/docs/database-drift-detection.md b/docs/database-drift-detection.md
index 92393a50f1..4ddec33035 100644
--- a/docs/database-drift-detection.md
+++ b/docs/database-drift-detection.md
@@ -13,12 +13,12 @@ application-owned object against `supabase/schema.sql`.
Three committed artifacts:
-| Artifact | Role |
-| ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `supabase/migrations/20261021180000_schema_drift_snapshot_literal_safe_comments.sql` | `public.schema_drift_snapshot()` v2, latest definer — service-role-only RPC returning the normalized live inventory plus the migration-history probe (supersedes `20260818090000` and `20260706200000`; also declared in `supabase/schema.sql`; a test enforces byte parity with the latest definer). |
-| `supabase/drift-manifest.json` | The expected state: the same snapshot captured from a **from-scratch replay of `supabase/schema.sql`** into a disposable `supabase/postgres` Docker container (`npm run drift:manifest`). Embeds the sha256 of the schema.sql it came from. |
-| `supabase/drift-allowlist.json` | Known, documented divergence (each entry has a `reason`; `migration_history` entries also need a `guard`). Reported as warnings; anything not listed fails the check. |
-| `supabase/search-health-unmonitored-indexes.json` | The runtime index-monitoring ratchet: every repo-defined index on a retrieval-critical table that `search_schema_health()` does not monitor, with a reason and disposition (see below). |
+| Artifact | Role |
+| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `supabase/migrations/20261105144240_audit_private_evidence_lifecycle_and_drift.sql` | `public.schema_drift_snapshot()` v2, latest definer — service-role-only RPC returning the normalized live inventory plus the migration-history probe (supersedes `20261021180000`, `20260818090000` and `20260706200000`; also declared in `supabase/schema.sql`; a test enforces byte parity with the latest definer). |
+| `supabase/drift-manifest.json` | The expected state: the same snapshot captured from a **from-scratch replay of `supabase/schema.sql`** into a disposable `supabase/postgres` Docker container (`npm run drift:manifest`). Embeds the sha256 of the schema.sql it came from. |
+| `supabase/drift-allowlist.json` | Known, documented divergence (each entry has a `reason`; `migration_history` entries also need a `guard`). Reported as warnings; anything not listed fails the check. |
+| `supabase/search-health-unmonitored-indexes.json` | The runtime index-monitoring ratchet: every repo-defined index on a retrieval-critical table that `search_schema_health()` does not monitor, with a reason and disposition (see below). |
`npm run check:drift` (needs live service-role env) verifies the project ref,
fails fast if the manifest is stale, calls the RPC, diffs, applies the
@@ -33,9 +33,20 @@ md5 + sorted ACLs), indexes (normalized `pg_get_indexdef`), RLS policies
RLS flags, reloptions, ACLs), constraints, triggers, views, extensions, and
storage bucket rows + storage.objects policies.
+The additive `private_objects` category covers the explicitly named application schema
+`ingestion_generation_evidence`. Each entry has a schema-qualified `identity` and a
+`state`: schema/relation/function ownership and effective ACLs, column ACLs, scoped
+default privileges and postgres global defaults, columns/defaults, RLS/FORCE RLS,
+constraints and validation, index validity/readiness, trigger enabled state, policies,
+views/sequences, and private function definitions/configuration. Extension-owned objects
+and other schemas remain excluded. Private function hashes preserve the complete
+definition (only CRLF line endings are normalized), so quoted content is never stripped.
+An expected private object missing from an older live snapshot fails the comparison;
+regenerate the manifest through the normal replay route when adopting this inventory.
+
### Noise sources handled by design
-- **Whitespace/comments in function bodies** — `prosrc` is stored verbatim, so
+- **Whitespace/comments in public function bodies** — `prosrc` is stored verbatim, so
migration text vs schema.sql text differ trivially; both are stripped before
hashing (the same trick `20260701140631` used to validate byte-equivalence).
Since `20261021180000` a `--` starts a line comment only where no `'...'` or `"..."`
@@ -65,7 +76,7 @@ What the check does **not** see — established offline on 2026-08-12 while
answering `/issues` `#248` (the 20260705180000 search-health indexes that were
missing on live despite an applied history):
-- **Invalid indexes read as healthy.** `schema_drift_snapshot()`
+- **Invalid public indexes read as healthy.** `schema_drift_snapshot()`
(`20260706200000`) builds its index rows from `pg_index` via
`pg_get_indexdef` + an md5 `def_hash`, and never reads `indisvalid` /
`indisready`. An index left behind by a failed `CREATE INDEX CONCURRENTLY`
diff --git a/docs/disaster-recovery-runbook.md b/docs/disaster-recovery-runbook.md
index 415428f682..ed0d0776d3 100644
--- a/docs/disaster-recovery-runbook.md
+++ b/docs/disaster-recovery-runbook.md
@@ -237,3 +237,9 @@ A schema restore is not operationally complete until all five environment-owned
timed) is planned step by step in
[`operator-rehearsal-plan.md`](operator-rehearsal-plan.md) (ledger `#QVVS6W`).
It needs the owner's approval before it runs.
+ Its acceptance includes a source snapshot-bound bucket/path/size/SHA-256 manifest,
+ actual GETs of all restored object bytes into a fresh download, and restored
+ application owner/access checks. `storage.objects` counts alone cannot prove this.
+ The offline `scripts/verify-storage-restore.mjs` tool and synthetic negative controls
+ verify the byte-checking mechanism only; they do not establish a completed restore,
+ current backup entitlement, RPO/RTO, Auth/key recovery or provider configuration.
diff --git a/docs/error-tracking.md b/docs/error-tracking.md
index 12832d9d36..91fd26356c 100644
--- a/docs/error-tracking.md
+++ b/docs/error-tracking.md
@@ -95,6 +95,21 @@ View samples in Sentry under **Explore → Logs**. Set `SENTRY_ENABLE_LOGS=false
## Operator approval and rollout
+The GitHub Sentry poller (`.github/workflows/sentry-alerts.yml`) reports **new groups** only:
+unresolved error/fatal groups first seen in its six-hour lookback, deduplicated by issue ID.
+It does not report every recurrence of an older group and cannot guarantee catch-up after a
+longer polling outage. The daily summary's “New errors (Sentry)” line describes this limited
+signal. A missing poller token now fails the workflow as unavailable; a fresh non-successful
+detector run is non-green in the summary. A later successful run establishes detector recovery,
+while an outstanding service-failure issue retains its own severity. These are source contracts,
+not evidence of current Sentry configuration, received events or notification delivery.
+
+If ongoing recurrence coverage is required, first agree its recurrence/cooldown and outage
+catch-up policy. A last-seen query alone is insufficient while persistent issue-ID deduplication
+remains. An implementation would need a bounded recurrence cursor, privacy-safe report markers,
+pagination/burst limits and synthetic old-group-recurrence/outage/recovery tests. Provider-side
+alert rules and delivery must be checked separately under the existing approval boundary.
+
Before setting `SENTRY_DSN`, the operator must approve the vendor/project, data region, retention period, access roles, sampling rate, cost budget, and alert destination. Configure a server-side DSN only; never use a `NEXT_PUBLIC_*` DSN. Keep provider-side IP/user enrichment disabled and restrict project access. Start with a non-production synthetic exception and inspect the received event before enabling production alerts.
When enabling tracing, also review a sampled transaction in Sentry and confirm span descriptions contain only table/operation metadata (no filter literals, clinical text, or mutation payloads). Set `SENTRY_TRACES_SAMPLE_RATE=0` to roll tracing back without removing the DSN.
diff --git a/docs/operator-rehearsal-plan.md b/docs/operator-rehearsal-plan.md
index be845ab049..3fa6880955 100644
--- a/docs/operator-rehearsal-plan.md
+++ b/docs/operator-rehearsal-plan.md
@@ -103,6 +103,13 @@ restores the latest production backup into a **new** project, copies the Storage
times both. See [`disaster-recovery-runbook.md`](disaster-recovery-runbook.md) for what a database
restore does and does not bring back.
+Before a provider rehearsal, exercise the byte acceptance below on an isolated local root using
+wholly invented document, image and CME evidence files. Keep its three bucket directories separate
+from all real backups. The focused `tests/storage-restore-bytes.test.ts` covers complete copies,
+missing objects, same-size corruption, unreadable object shapes and symlinks without providers.
+This local check is tooling evidence; it does not rehearse database, Auth, application access,
+backup entitlement, production backups or an operational RPO/RTO.
+
**2a. Start the database restore (timed).**
**Prerequisite, before requesting any restore.** Supabase copies enabled `pg_cron` jobs into a
@@ -153,6 +160,9 @@ supabase storage cp -r ss:///clinical-images "$DR_DIR/clinical-images" --experim
supabase storage cp -r ss:///cme-private-evidence "$DR_DIR/cme-private-evidence" --experimental --linked
supabase unlink
+# Reconcile the source snapshot inventory first; retain this private baseline outside DR_DIR.
+node scripts/verify-storage-restore.mjs manifest --root "$DR_DIR" --output "$DR_DIR-source-manifest.json"
+
supabase link --project-ref
cat supabase/.temp/project-ref # must print
supabase storage cp -r "$DR_DIR/clinical-documents" ss:///clinical-documents --experimental --linked
@@ -161,23 +171,23 @@ supabase storage cp -r "$DR_DIR/cme-private-evidence" ss:///cme-private-evidence
supabase unlink
```
-**2c. Verify the restored copy.** In the restore project's SQL editor (never production's):
+**2c. Verify metadata and actual restored bytes.** In the restore project's SQL editor (never production's):
```sql
SELECT (public.search_schema_health())->>'ok' AS schema_ok;
SELECT count(*) AS documents FROM public.documents;
SELECT count(*) AS chunks FROM public.document_chunks;
SELECT bucket_id, count(*) FROM storage.objects GROUP BY bucket_id ORDER BY bucket_id;
--- Documents whose file did not arrive
-SELECT count(*) AS missing_files
+-- Metadata coverage only: this does not read an object's bytes.
+SELECT count(*) AS missing_file_metadata
FROM public.documents d
WHERE d.storage_path IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM storage.objects o
WHERE o.bucket_id = 'clinical-documents' AND o.name = d.storage_path
);
--- CME evidence records whose file did not arrive (removed evidence has no file by design)
-SELECT count(*) AS missing_cme_evidence_files
+-- Metadata coverage only (removed evidence has no file by design).
+SELECT count(*) AS missing_cme_evidence_metadata
FROM public.cme_evidence e
WHERE e.removed_at IS NULL
AND NOT EXISTS (
@@ -190,6 +200,53 @@ Run the same counts (not the two missing-file queries) read-only on production f
"Post-restore environment recovery controls" checklist in the disaster-recovery runbook, but **keep
`invoke_indexing_v3_agent` inactive** on the restore project, so it never calls the production agent.
+Metadata counts and a successful copy command are insufficient for acceptance. The manifest step
+in 2b must run before uploading the source download, creating a private baseline of source bytes.
+
+`DR_DIR` must contain exactly `clinical-documents`, `clinical-images` and `cme-private-evidence`
+directories. Confirm the CLI's download layout before generating it. Reconcile the manifest's full
+bucket/path inventory against the approved source snapshot's Storage metadata and document/CME
+references; an incomplete source download cannot become a complete backup merely by hashing it.
+Record the source snapshot/backup time and manifest SHA-256. The file contains private paths and
+digests: retain it encrypted with the rehearsal evidence, outside the repository and shared logs.
+The verifier refuses to overwrite it. Never regenerate that baseline from the restored copy.
+
+After uploading, select the restore ref again, verify the linked target before **each** Storage
+command, and use actual Storage GET/downloads into a **new empty** private directory
+`DR_VERIFY_DIR`. With the confirmed CLI syntax from 2b:
+
+```bash
+test ! -e "$DR_VERIFY_DIR" && mkdir "$DR_VERIFY_DIR" # stop if this fails
+supabase link --project-ref
+cat supabase/.temp/project-ref # must print
+supabase storage cp -r ss:///clinical-documents "$DR_VERIFY_DIR/clinical-documents" --experimental --linked
+cat supabase/.temp/project-ref # must print
+supabase storage cp -r ss:///clinical-images "$DR_VERIFY_DIR/clinical-images" --experimental --linked
+cat supabase/.temp/project-ref # must print
+supabase storage cp -r ss:///cme-private-evidence "$DR_VERIFY_DIR/cme-private-evidence" --experimental --linked
+supabase unlink
+node scripts/verify-storage-restore.mjs verify --root "$DR_VERIFY_DIR" --manifest "$DR_DIR-source-manifest.json"
+```
+
+Do not copy from `DR_DIR`, reuse a populated directory, or count `storage.objects` rows as reads.
+
+Acceptance requires exit 0, `ok: true`, `verified` equal to `expected`, no failures and the exact
+recorded manifest SHA-256. Every object is opened, fully read and compared for size and SHA-256;
+missing/corrupt/unreadable bytes, extra objects and symlinks fail. Bounds are 100,000 objects,
+512 MiB per object and a 32 MiB manifest; exceeding them is a failed acceptance needing a separately
+reviewed plan, never permission to sample silently. Run the missing-object and same-size-corruption
+negative controls only on a disposable synthetic copy and retain their expected failures.
+
+Finally verify representative original-document, image and private CME downloads through the
+restored application, using the approved isolated target identity procedure. The intended owner
+must receive readable bytes matching the baseline; another owner and a signed-out caller must be
+denied private evidence. Record the synthetic identities, authorization outcome, content digest,
+application/worker/schema versions, Auth mapping, configuration/key recovery status and times.
+Keep production cron and external callbacks contained. A service-role download cannot replace
+these application access checks. No provider check or account creation runs without its existing
+approval. Finish RTO timing only when all approved operational acceptance steps have completed;
+otherwise record the remaining blocker and elapsed partial time.
+
Optional, with separate approval: point a throwaway env file at the restore project and run
`npm run check:indexing` and `npm run eval:retrieval:quality` (must pass 36/36). The retrieval eval
sends about 36 embedding requests to OpenAI (well under US$0.10). If the project identity guard refuses
@@ -205,7 +262,7 @@ plan's egress allowance; record the total size. Deleting the project the same da
estimate; or the restore has not finished after four hours (record that as the finding).
**Evidence to record:** backup plan setting and newest backup time; restore start, healthy and
-finish times; Storage size and copy time; the count comparison; `missing_files` and `missing_cme_evidence_files` (both must be 0); anything that had to
+finish times; source backup/snapshot time and resulting RPO; Storage size and copy/download/byte-verification time; the count comparison; `missing_file_metadata` and `missing_cme_evidence_metadata` (both must be 0); private manifest identity and byte-verifier result; synthetic negative controls; restored application owner/other-owner/signed-out download outcomes; completed operational RTO or explicit blockers; anything that had to
be re-entered by hand; and the eval result if run.
## 3. 30-user load test against staging
diff --git a/docs/organisation/systems/delivery.json b/docs/organisation/systems/delivery.json
index 4eadb66e01..98226e4f10 100644
--- a/docs/organisation/systems/delivery.json
+++ b/docs/organisation/systems/delivery.json
@@ -181,6 +181,7 @@
"scripts/verify-phone-chrome.mjs",
"scripts/verify-pr-local.mjs",
"scripts/verify-release-offline.mjs",
+ "scripts/verify-storage-restore.mjs",
"scripts/worktree-cleanup.mjs",
"scripts/worktree-inventory.mjs",
"scripts/yaml-contract.mjs",
@@ -281,6 +282,7 @@
"tests/soak-test.test.ts",
"tests/source-contract-helper.test.ts",
"tests/source-control-bytes.test.ts",
+ "tests/storage-restore-bytes.test.ts",
"tests/stubs/**",
"tests/summarise-web-vitals.test.ts",
"tests/sweep-scripts-offline-default.test.ts",
diff --git a/docs/organisation/systems/source-intake.json b/docs/organisation/systems/source-intake.json
index 90cff71ce2..3e97ed037f 100644
--- a/docs/organisation/systems/source-intake.json
+++ b/docs/organisation/systems/source-intake.json
@@ -51,6 +51,7 @@
"scripts/ingestion-autopilot.ts",
"scripts/inspect-shadow-extraction.ts",
"scripts/lib/enrich-documents-args.ts",
+ "scripts/lib/import-document-queue.ts",
"scripts/lib/indexing-health-document.ts",
"scripts/lib/registry-projection-document.ts",
"scripts/measure-wrapped-dose-prevalence.ts",
@@ -109,6 +110,7 @@
"src/lib/ingestion-audit.ts",
"src/lib/ingestion-enqueue.ts",
"src/lib/ingestion-generation-evidence.ts",
+ "src/lib/ingestion-maintenance.ts",
"src/lib/ingestion-mutation-safety.ts",
"src/lib/ingestion-provider-egress.ts",
"src/lib/ingestion-recovery.ts",
diff --git a/docs/worker-deploy-runbook.md b/docs/worker-deploy-runbook.md
index 031d992ddb..21ad88283f 100644
--- a/docs/worker-deploy-runbook.md
+++ b/docs/worker-deploy-runbook.md
@@ -506,6 +506,20 @@ duration of the docling run (up to 120 s) before the final metadata merge and
`complete_ingestion_job`. So the effect is on queue drain rate and on `jobs_processing` /
`jobs_pending`, which is why §3.6 watches those and not answer latency.
+**Child-process environment (source contract, 2026-11-05).** The shadow parser inherits an
+explicit runtime allowlist, rather than the worker's full environment. Credentials, proxy
+configuration and interpreter-injection variables are excluded. Each run gets private home,
+temporary and default cache directories; its existing cleanup removes that generated run root
+after success, failure or timeout. Model/cache paths remain shared only when they are inside
+the explicitly configured `DOCLING_ARTIFACTS_PATH`; those artifact paths are never deleted by
+cleanup. HF/Transformers/dataset offline flags are forced even if the worker's ambient flags
+are `0`. This reduces credential exposure and incidental downloads; it is **not an OS network
+or filesystem sandbox**, and does not remediate the accepted Pillow advisories. Local tests use
+real stand-in child processes to verify environment/cleanup wiring; they do not substitute for
+the image's real model-backed Docling/Tesseract control or the preconditions below. A custom
+model cache outside the artifact tree needs a reviewed artifact layout before relying on it;
+the runner will use its private cache instead and will not delete that external cache.
+
### 3.2 Preconditions before enabling
1. **The running image must be built from `main` at or after PR #2170 (`5437c309f`).** That
diff --git a/scripts/archive/backfill-document-tags.ts b/scripts/archive/backfill-document-tags.ts
index c980556c5b..5ba043e53f 100644
--- a/scripts/archive/backfill-document-tags.ts
+++ b/scripts/archive/backfill-document-tags.ts
@@ -1,3 +1,4 @@
+import { replaceMaintenanceArtifacts } from "@/lib/ingestion-maintenance";
import { loadEnvConfig } from "@next/env";
import { normalizeDocumentLabelForStorage } from "@/lib/document-tags";
import type { DocumentLabel, DocumentLabelType } from "@/lib/types";
@@ -21,6 +22,8 @@ type DocumentRow = {
file_name: string;
source_path?: string | null;
status: string;
+ metadata: Record;
+ updated_at: string;
};
type CleanGeneratedLabel = {
@@ -84,7 +87,7 @@ function parseArgs(argv: string[]): BackfillArgs {
async function loadDocuments(supabase: Awaited>, args: BackfillArgs) {
let query = supabase
.from("documents")
- .select("id,owner_id,title,file_name,source_path,status")
+ .select("id,owner_id,title,file_name,source_path,status,metadata,updated_at")
.eq("status", "indexed")
.order("created_at", { ascending: true })
.limit(args.documentId ? 1 : args.limit);
@@ -186,32 +189,25 @@ async function writePlan(
stampedAt: string,
ragEnrichmentVersion: string,
) {
- const { error: deleteError } = await supabase
- .from("document_labels")
- .delete()
- .eq("document_id", plan.document.id)
- .eq("source", "generated");
- if (deleteError) throw new Error(deleteError.message);
-
- if (plan.cleaned.length === 0) return;
- const { error: insertError } = await supabase.from("document_labels").insert(
- plan.cleaned.map((label) => ({
- document_id: plan.document.id,
- owner_id: plan.document.owner_id,
- label: label.label,
- label_type: label.label_type,
- source: "generated",
- confidence: label.confidence,
- metadata: {
- generated_by: "smart-tag-backfill",
- source_label_ids: label.sourceLabelIds,
- source_label_examples: label.examples,
- rag_enrichment_version: ragEnrichmentVersion,
- smart_tag_backfilled_at: stampedAt,
- },
- })),
- );
- if (insertError) throw new Error(insertError.message);
+ await replaceMaintenanceArtifacts({
+ supabase,
+ document: plan.document,
+ operation: "labels",
+ payload: {
+ labels: plan.cleaned.map((label) => ({
+ label: label.label,
+ label_type: label.label_type,
+ confidence: label.confidence,
+ metadata: {
+ generated_by: "smart-tag-backfill",
+ source_label_ids: label.sourceLabelIds,
+ source_label_examples: label.examples,
+ rag_enrichment_version: ragEnrichmentVersion,
+ smart_tag_backfilled_at: stampedAt,
+ },
+ })),
+ },
+ });
}
function printPlan(plans: Array>, write: boolean) {
diff --git a/scripts/archive/backfill-enrichment.ts b/scripts/archive/backfill-enrichment.ts
index 69d25cec8e..39f1685822 100644
--- a/scripts/archive/backfill-enrichment.ts
+++ b/scripts/archive/backfill-enrichment.ts
@@ -1,3 +1,9 @@
+import {
+ patchMaintenanceDocument,
+ assertLegacyMaintenanceSnapshot,
+ replaceMaintenanceArtifacts,
+ maintenanceMetadata,
+} from "@/lib/ingestion-maintenance";
import { createHash } from "node:crypto";
import { loadEnvConfig } from "@next/env";
import type { SupabaseClient } from "@supabase/supabase-js";
@@ -32,6 +38,7 @@ type BackfillDocument = {
file_name: string;
source_path: string | null;
metadata: Record | null;
+ updated_at: string;
};
type BackfillChunk = {
@@ -97,12 +104,6 @@ function parseArgs(argv: string[]): BackfillArgs {
return args;
}
-function metadataRecord(metadata: unknown): Record {
- return metadata && typeof metadata === "object" && !Array.isArray(metadata)
- ? { ...(metadata as Record) }
- : {};
-}
-
function hashContent(content: string) {
return createHash("md5").update(content).digest("hex");
}
@@ -150,23 +151,21 @@ async function updateDocumentStage(
stage: string,
patch: Record = {},
) {
+ assertLegacyMaintenanceSnapshot(document);
const metadata = {
- ...metadataRecord(document.metadata),
...patch,
enrichment_status: patch.enrichment_status ?? (stage === "completed" ? "completed" : "processing"),
enrichment_stage: stage,
document_intelligence_version: documentIntelligenceVersion,
document_intelligence_updated_at: new Date().toISOString(),
};
- document.metadata = metadata;
- const { error } = await supabase.from("documents").update({ metadata }).eq("id", document.id);
- if (error) throw new Error(supabaseErrorMessage(error));
+ await patchMaintenanceDocument({ supabase, document, patch: metadata });
}
async function loadDocuments(supabase: SupabaseAdmin, args: BackfillArgs) {
let query = supabase
.from("documents")
- .select("id,owner_id,title,file_name,source_path,metadata")
+ .select("id,owner_id,title,file_name,source_path,metadata,updated_at")
.eq("status", "indexed")
.order("created_at", { ascending: true })
.limit(args.documentId ? 1 : Math.max(args.limit * 8, args.limit));
@@ -183,12 +182,17 @@ async function selectRowsInPages(
supabase: SupabaseAdmin,
table: "document_chunks" | "document_images",
select: string,
- documentId: string,
+ document: BackfillDocument,
searchableOnly = false,
) {
const rows: T[] = [];
for (let offset = 0; ; offset += pageSize) {
- let query = supabase.from(table).select(select).eq("document_id", documentId);
+ let query = supabase.from(table).select(select).eq("document_id", document.id);
+ const generation = maintenanceMetadata(document.metadata).index_generation_id;
+ query =
+ typeof generation === "string"
+ ? query.eq("index_generation_id", generation)
+ : query.is("index_generation_id", null);
if (searchableOnly) query = query.eq("searchable", true);
const { data, error } = await query.range(offset, offset + pageSize - 1);
if (error) throw new Error(supabaseErrorMessage(error));
@@ -198,19 +202,19 @@ async function selectRowsInPages(
return rows;
}
-async function loadEvidence(supabase: SupabaseAdmin, documentId: string) {
+async function loadEvidence(supabase: SupabaseAdmin, document: BackfillDocument) {
const [chunks, images] = await Promise.all([
selectRowsInPages(
supabase,
"document_chunks",
"id,document_id,page_number,chunk_index,section_heading,section_path,anchor_id,content,image_ids,metadata",
- documentId,
+ document,
),
selectRowsInPages(
supabase,
"document_images",
"id,page_number,caption,image_type,labels,source_kind,clinical_relevance_score,metadata",
- documentId,
+ document,
true,
),
]);
@@ -237,7 +241,7 @@ async function insertDocumentLevelEmbeddingFields(args: {
content: compactSearchText(`${args.document.title} ${args.summary ?? ""}`, 1200),
},
].filter((input) => input.content);
- const existing = await args.supabase
+ let existingQuery = args.supabase
.from("document_embedding_fields")
.select("field_type,content_hash")
.eq("document_id", args.document.id)
@@ -245,6 +249,12 @@ async function insertDocumentLevelEmbeddingFields(args: {
"field_type",
inputs.map((input) => input.field_type),
);
+ const generation = maintenanceMetadata(args.document.metadata).index_generation_id;
+ existingQuery =
+ typeof generation === "string"
+ ? existingQuery.eq("index_generation_id", generation)
+ : existingQuery.is("index_generation_id", null);
+ const existing = await existingQuery;
if (existing.error) throw new Error(supabaseErrorMessage(existing.error));
const existingKeys = new Set((existing.data ?? []).map((row) => `${row.field_type}:${row.content_hash}`));
const missing = inputs.filter((input) => !existingKeys.has(`${input.field_type}:${hashContent(input.content)}`));
@@ -265,16 +275,24 @@ async function insertDocumentLevelEmbeddingFields(args: {
document_intelligence_version: documentIntelligenceVersion,
},
}));
- const { error } = await args.supabase.from("document_embedding_fields").insert(rows);
- if (error) throw new Error(supabaseErrorMessage(error));
+ await replaceMaintenanceArtifacts({
+ supabase: args.supabase,
+ document: args.document,
+ operation: "embedding-fields",
+ payload: { fields: rows },
+ });
return inputs.map((input) => input.field_type);
}
-async function countRows(supabase: SupabaseAdmin, table: string, documentId: string) {
- const query = supabase
- .from(table)
- .select("document_id", { count: "exact", head: true })
- .eq("document_id", documentId);
+async function countRows(supabase: SupabaseAdmin, table: string, document: BackfillDocument) {
+ let query = supabase.from(table).select("document_id", { count: "exact", head: true }).eq("document_id", document.id);
+ if (!["document_summaries", "document_labels", "document_index_quality"].includes(table)) {
+ const generation = maintenanceMetadata(document.metadata).index_generation_id;
+ query =
+ typeof generation === "string"
+ ? query.eq("index_generation_id", generation)
+ : query.is("index_generation_id", null);
+ }
const result = await query;
if (result.error) throw new Error(supabaseErrorMessage(result.error));
return result.count ?? 0;
@@ -290,15 +308,18 @@ type BackfillVerificationCounts = {
qualityRows: number;
};
-async function readBackfillCounts(supabase: SupabaseAdmin, documentId: string): Promise {
+async function readBackfillCounts(
+ supabase: SupabaseAdmin,
+ document: BackfillDocument,
+): Promise {
const [summaries, labels, sections, memoryCards, indexUnits, embeddingFields, qualityRows] = await Promise.all([
- countRows(supabase, "document_summaries", documentId),
- countRows(supabase, "document_labels", documentId),
- countRows(supabase, "document_sections", documentId),
- countRows(supabase, "document_memory_cards", documentId),
- countRows(supabase, "document_index_units", documentId),
- countRows(supabase, "document_embedding_fields", documentId),
- countRows(supabase, "document_index_quality", documentId),
+ countRows(supabase, "document_summaries", document),
+ countRows(supabase, "document_labels", document),
+ countRows(supabase, "document_sections", document),
+ countRows(supabase, "document_memory_cards", document),
+ countRows(supabase, "document_index_units", document),
+ countRows(supabase, "document_embedding_fields", document),
+ countRows(supabase, "document_index_quality", document),
]);
return { summaries, labels, sections, memoryCards, indexUnits, embeddingFields, qualityRows };
}
@@ -309,21 +330,23 @@ function missingBackfillCounts(counts: BackfillVerificationCounts) {
.map(([key]) => key);
}
-async function verifyBackfill(supabase: SupabaseAdmin, documentId: string) {
- const counts = await readBackfillCounts(supabase, documentId);
+async function verifyBackfill(supabase: SupabaseAdmin, document: BackfillDocument) {
+ const counts = await readBackfillCounts(supabase, document);
const missing = missingBackfillCounts(counts);
if (missing.length) throw new Error(`Backfill verification failed; missing ${missing.join(", ")}.`);
return counts;
}
-async function countIndexUnitsByType(supabase: SupabaseAdmin, documentId: string) {
+async function countIndexUnitsByType(supabase: SupabaseAdmin, document: BackfillDocument) {
const rows: { unit_type: string }[] = [];
for (let offset = 0; ; offset += pageSize) {
- const { data, error } = await supabase
- .from("document_index_units")
- .select("unit_type")
- .eq("document_id", documentId)
- .range(offset, offset + pageSize - 1);
+ let query = supabase.from("document_index_units").select("unit_type").eq("document_id", document.id);
+ const generation = maintenanceMetadata(document.metadata).index_generation_id;
+ query =
+ typeof generation === "string"
+ ? query.eq("index_generation_id", generation)
+ : query.is("index_generation_id", null);
+ const { data, error } = await query.range(offset, offset + pageSize - 1);
if (error) throw new Error(supabaseErrorMessage(error));
rows.push(...((data ?? []) as { unit_type: string }[]));
if (!data || data.length < pageSize) break;
@@ -344,7 +367,7 @@ async function markBackfillCompleted(args: {
indexUnitCount?: number;
documentEmbeddingFieldTypes?: string[];
}) {
- const indexUnitCountsByType = await countIndexUnitsByType(args.supabase, args.document.id);
+ const indexUnitCountsByType = await countIndexUnitsByType(args.supabase, args.document);
const patch: Record = {
enrichment_status: "completed",
enrichment_error: null,
@@ -365,7 +388,7 @@ async function markBackfillCompleted(args: {
}
async function tryFinalizeExistingBackfill(supabase: SupabaseAdmin, document: BackfillDocument) {
- const counts = await readBackfillCounts(supabase, document.id);
+ const counts = await readBackfillCounts(supabase, document);
const missing = Object.entries(counts)
.filter(([, count]) => count <= 0)
.map(([key]) => key);
@@ -375,8 +398,9 @@ async function tryFinalizeExistingBackfill(supabase: SupabaseAdmin, document: Ba
}
async function processDocument(supabase: SupabaseAdmin, document: BackfillDocument) {
+ assertLegacyMaintenanceSnapshot(document);
await updateDocumentStage(supabase, document, "loading_evidence");
- const evidence = await loadEvidence(supabase, document.id);
+ const evidence = await loadEvidence(supabase, document);
if (evidence.chunks.length === 0) throw new Error("Document has no indexed chunks to enrich.");
const finalizedCounts = await tryFinalizeExistingBackfill(supabase, document);
@@ -421,7 +445,7 @@ async function processDocument(supabase: SupabaseAdmin, document: BackfillDocume
});
await updateDocumentStage(supabase, document, "verifying_backfill");
- const counts = await verifyBackfill(supabase, document.id);
+ const counts = await verifyBackfill(supabase, document);
await markBackfillCompleted({
supabase,
document,
diff --git a/scripts/backfill-visual-intelligence.ts b/scripts/backfill-visual-intelligence.ts
index 02b5b83d6d..20076ceb47 100644
--- a/scripts/backfill-visual-intelligence.ts
+++ b/scripts/backfill-visual-intelligence.ts
@@ -1,5 +1,9 @@
+import {
+ assertLegacyMaintenanceSnapshot,
+ maintenanceMetadata,
+ replaceMaintenanceArtifacts,
+} from "@/lib/ingestion-maintenance";
import { loadEnvConfig } from "@next/env";
-import type { Json, TablesInsert } from "@/lib/supabase/database.types";
import {
buildVisualDocumentIndexUnitInputs,
embeddingTextForDocumentIndexUnit,
@@ -34,6 +38,7 @@ type BackfillImageRow = {
source_kind: string | null;
labels: string[] | null;
metadata: Record | null;
+ index_generation_id: string | null;
};
type BackfillDocumentRow = {
@@ -41,6 +46,8 @@ type BackfillDocumentRow = {
owner_id: string | null;
title: string;
file_name: string;
+ metadata: Record;
+ updated_at: string;
};
function argValue(name: string, fallback: string) {
@@ -123,7 +130,7 @@ function imageToVisualInput(image: BackfillImageRow): IndexUnitVisualImage {
async function loadCandidateImages() {
const { data, error } = await supabase
.from("document_images")
- .select("id,document_id,page_number,caption,image_type,source_kind,labels,metadata")
+ .select("id,document_id,page_number,caption,image_type,source_kind,labels,metadata,index_generation_id")
.eq("searchable", true)
.or(
[
@@ -223,35 +230,32 @@ async function printCoverageReport(images: BackfillImageRow[]) {
async function loadDocument(documentId: string) {
const { data, error } = await supabase
.from("documents")
- .select("id,owner_id,title,file_name")
+ .select("id,owner_id,title,file_name,metadata,updated_at")
.eq("id", documentId)
.single();
if (error) throw new Error(error.message);
return data as BackfillDocumentRow;
}
-async function loadChunks(documentId: string) {
- const { data, error } = await supabase
+async function loadChunks(documentId: string, generation: string | null) {
+ let query = supabase
.from("document_chunks")
.select("id,document_id,page_number,chunk_index,section_heading,section_path,content,metadata,image_ids")
- .eq("document_id", documentId)
- .order("chunk_index", { ascending: true });
+ .eq("document_id", documentId);
+ query = generation ? query.eq("index_generation_id", generation) : query.is("index_generation_id", null);
+ const { data, error } = await query.order("chunk_index", { ascending: true });
if (error) throw new Error(error.message);
return (data ?? []) as IndexUnitChunk[];
}
-async function markImage(image: BackfillImageRow, patch: Record) {
- const metadata = { ...(image.metadata ?? {}), ...patch };
- const { error } = await supabase
- .from("document_images")
- .update({ metadata: metadata as Json })
- .eq("id", image.id);
- if (error) throw new Error(error.message);
-}
-
async function backfillDocument(documentId: string, images: BackfillImageRow[]) {
const document = await loadDocument(documentId);
- const chunks = await loadChunks(documentId);
+ if (!dryRun) assertLegacyMaintenanceSnapshot(document);
+ const generationValue = maintenanceMetadata(document.metadata).index_generation_id;
+ const generation = typeof generationValue === "string" ? generationValue : null;
+ images = images.filter((image) => image.index_generation_id === generation);
+ if (images.length === 0) return;
+ const chunks = await loadChunks(documentId, generation);
const visualImages = images.map(imageToVisualInput);
const units = buildVisualDocumentIndexUnitInputs({ document, chunks, images: visualImages });
const counts = unitCounts(units);
@@ -260,55 +264,31 @@ async function backfillDocument(documentId: string, images: BackfillImageRow[])
);
if (dryRun) return;
- const imageIds = images.map((image) => image.id);
- for (const image of images) {
- await markImage(image, {
- visual_backfill_status: "processing",
- visual_backfill_started_at: new Date().toISOString(),
- });
- }
- const { error: deleteError } = await supabase
- .from("document_index_units")
- .delete()
- .eq("document_id", documentId)
- .in("source_image_id", imageIds)
- .in("unit_type", [
- "visual_summary",
- "flowchart_step",
- "diagram_decision",
- "risk_matrix_cell",
- "medication_chart_row",
- "chart_finding",
- "visual_askable_question",
- "table_threshold",
- ]);
- // Inserting on top of rows that failed to delete would leave duplicate visual units.
- if (deleteError) throw new Error(deleteError.message);
-
- if (units.length > 0) {
- const embeddings = await embedTexts(units.map(embeddingTextForDocumentIndexUnit));
- for (let start = 0; start < units.length; start += 50) {
- const batch = units.slice(start, start + 50).map((unit, index) => ({
- ...unit,
- embedding: embeddings[start + index],
- }));
- const { error } = await supabase
- .from("document_index_units")
- .insert(batch as unknown as TablesInsert<"document_index_units">[]);
- if (error) throw new Error(error.message);
- }
- }
-
- for (const image of images) {
- const profile = imageToVisualInput(image).structuredVisualProfile;
- await markImage(image, {
- visual_intelligence_version: visualIntelligenceVersion,
- structured_visual_profile: profile,
- structured_extraction_confidence: profile?.confidence ?? null,
- visual_backfill_status: "completed",
- visual_backfill_completed_at: new Date().toISOString(),
- });
- }
+ // Prepare every embedding before replacing any searchable evidence.
+ const embeddings = units.length ? await embedTexts(units.map(embeddingTextForDocumentIndexUnit)) : [];
+ if (embeddings.length !== units.length) throw new Error("Visual embedding count mismatch.");
+ await replaceMaintenanceArtifacts({
+ supabase,
+ document,
+ operation: "visual",
+ payload: {
+ image_ids: images.map((image) => image.id),
+ units: units.map((unit, index) => ({ ...unit, index_generation_id: generation, embedding: embeddings[index] })),
+ image_patches: images.map((image) => {
+ const profile = imageToVisualInput(image).structuredVisualProfile;
+ return {
+ id: image.id,
+ metadata: {
+ visual_intelligence_version: visualIntelligenceVersion,
+ structured_visual_profile: profile,
+ structured_extraction_confidence: profile?.confidence ?? null,
+ visual_backfill_status: "completed",
+ visual_backfill_completed_at: new Date().toISOString(),
+ },
+ };
+ }),
+ },
+ });
}
async function main() {
@@ -339,14 +319,8 @@ async function main() {
const message = error instanceof Error ? error.message : String(error);
failedDocuments += 1;
console.warn(`Visual backfill failed for document ${documentId}: ${message}`);
- if (!dryRun) {
- for (const image of documentImages) {
- await markImage(image, {
- visual_backfill_status: "retry",
- visual_backfill_error: message.slice(0, 500),
- });
- }
- }
+ // The original evidence and image metadata remain unchanged on failure.
+ // A later invocation recomputes from a fresh committed snapshot.
}
}
if (failedDocuments > 0) {
diff --git a/scripts/check-client-bundle-secrets.mjs b/scripts/check-client-bundle-secrets.mjs
index 7e403f35af..6fdb9f29d4 100644
--- a/scripts/check-client-bundle-secrets.mjs
+++ b/scripts/check-client-bundle-secrets.mjs
@@ -40,10 +40,18 @@ function textFiles(root) {
}
const publicRoot = join(projectRoot, "public");
-const clientBuildRoot = join(projectRoot, ".next", "static");
+const requestedDistDir = process.env.NEXT_DIST_DIR?.trim();
+// Match next.config.ts: a selected isolated build must never fall back to a different
+// successful .next build, or the marker check could inspect unrelated/stale artifacts.
+if (requestedDistDir && !/^\.next-playwright\/[a-z0-9-]+\/dist$/i.test(requestedDistDir)) {
+ console.error("NEXT_DIST_DIR must be an owned .next-playwright//dist directory.");
+ process.exit(1);
+}
+const distDir = requestedDistDir || ".next";
+const clientBuildRoot = join(projectRoot, distDir, "static");
if (!existsSync(clientBuildRoot)) {
- console.error("Client bundle secret surface check requires .next/static. Run it after next build.");
+ console.error(`Client bundle secret surface check requires ${distDir}/static. Run it after next build.`);
process.exit(1);
}
diff --git a/scripts/check-drift.ts b/scripts/check-drift.ts
index ea6979e06e..40962b697c 100644
--- a/scripts/check-drift.ts
+++ b/scripts/check-drift.ts
@@ -138,6 +138,7 @@ const categoryKeys: Record string> = {
constraints: (o) => `${o.table}.${o.name}`,
triggers: (o) => `${o.table}.${o.name}`,
storage_buckets: (o) => String(o.id),
+ private_objects: (o) => String(o.identity),
};
// Fields that identify content per category (compared as canonical JSON).
@@ -151,6 +152,7 @@ const comparedFields: Record = {
constraints: ["def"],
triggers: ["def"],
storage_buckets: ["public", "file_size_limit", "allowed_mime_types"],
+ private_objects: ["state"],
};
function canonical(value: unknown): string {
diff --git a/scripts/enrich-documents.ts b/scripts/enrich-documents.ts
index fd891c85de..159617fff3 100644
--- a/scripts/enrich-documents.ts
+++ b/scripts/enrich-documents.ts
@@ -1,6 +1,12 @@
+import {
+ patchMaintenanceDocument,
+ replaceMaintenanceArtifacts,
+ assertLegacyMaintenanceSnapshot,
+ maintenanceMetadata,
+ type MaintenanceDocument,
+} from "@/lib/ingestion-maintenance";
import { loadEnvConfig } from "@next/env";
import type { SupabaseClient } from "@supabase/supabase-js";
-import type { Json } from "@/lib/supabase/database.types";
import { createHash } from "node:crypto";
import { type EnrichArgs, parseEnrichArgs } from "./lib/enrich-documents-args";
import { findOwnerIdByEmail } from "./lib/find-owner-id-by-email";
@@ -18,7 +24,7 @@ async function loadAdminClient() {
async function loadDocuments(supabase: SupabaseAdmin, args: EnrichArgs, ownerId?: string) {
let query = supabase
.from("documents")
- .select("id,owner_id,title,file_name,source_path,status,metadata")
+ .select("id,owner_id,title,file_name,source_path,status,metadata,updated_at")
.eq("status", "indexed")
.order("created_at", { ascending: true })
.limit(args.documentId ? 1 : Math.max(args.limit * 10, 1000));
@@ -115,7 +121,9 @@ async function loadDeepMemoryCoverage(supabase: SupabaseAdmin, documentIds: stri
return coverage;
}
-async function loadEvidence(supabase: SupabaseAdmin, documentId: string) {
+async function loadEvidence(supabase: SupabaseAdmin, document: MaintenanceDocument) {
+ const documentId = document.id;
+ const generation = maintenanceMetadata(document.metadata).index_generation_id;
const chunks: Array<{
id: string;
document_id: string;
@@ -140,25 +148,33 @@ async function loadEvidence(supabase: SupabaseAdmin, documentId: string) {
}> = [];
for (let start = 0; ; start += 1000) {
- const { data, error } = await supabase
+ let query = supabase
.from("document_chunks")
.select(
"id,document_id,page_number,chunk_index,section_heading,section_path,anchor_id,content,image_ids,metadata",
)
- .eq("document_id", documentId)
- .order("chunk_index", { ascending: true })
- .range(start, start + 999);
+ .eq("document_id", documentId);
+ query =
+ typeof generation === "string"
+ ? query.eq("index_generation_id", generation)
+ : query.is("index_generation_id", null);
+ const { data, error } = await query.order("chunk_index", { ascending: true }).range(start, start + 999);
if (error) throw new Error(error.message);
chunks.push(...(data ?? []));
if (!data || data.length < 1000) break;
}
for (let start = 0; ; start += 1000) {
- const { data, error } = await supabase
+ let query = supabase
.from("document_images")
.select("id,page_number,caption,image_type,labels,source_kind,clinical_relevance_score,metadata")
.eq("document_id", documentId)
- .eq("searchable", true)
+ .eq("searchable", true);
+ query =
+ typeof generation === "string"
+ ? query.eq("index_generation_id", generation)
+ : query.is("index_generation_id", null);
+ const { data, error } = await query
.order("clinical_relevance_score", { ascending: false })
.range(start, start + 999);
if (error) throw new Error(error.message);
@@ -222,7 +238,10 @@ function needsEnrichmentBackfill(args: {
);
}
-async function classifyExistingImages(supabase: SupabaseAdmin, documentId: string) {
+async function classifyExistingImages(supabase: SupabaseAdmin, document: MaintenanceDocument) {
+ const documentId = document.id;
+ const generation = maintenanceMetadata(document.metadata).index_generation_id;
+ const imagePatches: Array> = [];
const [
{ env },
{
@@ -236,13 +255,17 @@ async function classifyExistingImages(supabase: SupabaseAdmin, documentId: strin
},
{ classifyAndCaptionImageFromBase64 },
] = await Promise.all([import("@/lib/env"), import("@/lib/image-filtering"), import("@/lib/openai")]);
- const { data: images, error } = await supabase
+ let imageQuery = supabase
.from("document_images")
.select(
"id,page_number,storage_path,mime_type,caption,bbox,width,height,source_kind,image_hash,image_type,searchable,clinical_relevance_score,skip_reason,labels,metadata",
)
- .eq("document_id", documentId)
- .order("page_number", { ascending: true });
+ .eq("document_id", documentId);
+ imageQuery =
+ typeof generation === "string"
+ ? imageQuery.eq("index_generation_id", generation)
+ : imageQuery.is("index_generation_id", null);
+ const { data: images, error } = await imageQuery.order("page_number", { ascending: true });
if (error) throw new Error(error.message);
@@ -260,11 +283,11 @@ async function classifyExistingImages(supabase: SupabaseAdmin, documentId: strin
const download = await supabase.storage.from(env.SUPABASE_IMAGE_BUCKET).download(image.storage_path);
if (download.error || !download.data) {
- const { error: skipError } = await supabase
- .from("document_images")
- .update({ searchable: false, skip_reason: download.error?.message ?? "image unavailable in storage" })
- .eq("id", image.id);
- if (skipError) throw new Error(skipError.message);
+ imagePatches.push({
+ id: image.id,
+ searchable: false,
+ skip_reason: download.error?.message ?? "image unavailable in storage",
+ });
skipped += 1;
continue;
}
@@ -288,17 +311,14 @@ async function classifyExistingImages(supabase: SupabaseAdmin, documentId: strin
});
if (cheapSkip) {
- const { error: cheapSkipError } = await supabase
- .from("document_images")
- .update({
- searchable: false,
- skip_reason: cheapSkip,
- image_hash: imageHash,
- perceptual_hash: perceptualHash,
- clinical_relevance_score: 0,
- })
- .eq("id", image.id);
- if (cheapSkipError) throw new Error(cheapSkipError.message);
+ imagePatches.push({
+ id: image.id,
+ searchable: false,
+ skip_reason: cheapSkip,
+ image_hash: imageHash,
+ perceptual_hash: perceptualHash,
+ clinical_relevance_score: 0,
+ });
skipped += 1;
continue;
}
@@ -374,41 +394,51 @@ async function classifyExistingImages(supabase: SupabaseAdmin, documentId: strin
classification.image_type !== "logo_decorative";
const nextSearchable = finalAssessment.searchable;
- const { error: imageUpdateError } = await supabase
- .from("document_images")
- .update({
- caption: classification.caption || image.caption,
- image_type: classification.image_type,
- searchable: nextSearchable,
- clinical_relevance_score: nextSearchable ? finalAssessment.clinical_relevance_score : 0,
- skip_reason: nextSearchable ? null : classifiedSkip,
- image_hash: imageHash,
- perceptual_hash: perceptualHash,
- labels: classification.labels,
- metadata: {
- ...metadataRecord(image.metadata),
- clinical_use_class: finalAssessment.clinical_use_class,
- clinical_use_reason: finalAssessment.clinical_use_reason,
- clinical_signal_score: finalAssessment.clinical_signal_score,
- admin_signal_score: finalAssessment.admin_signal_score,
- image_policy_version: clinicalImagePolicyVersion,
- retained_for_audit: retainAsAuditTable || undefined,
- retained_for_document_view: retainAsAuditTable || undefined,
- },
- })
- .eq("id", image.id);
- if (imageUpdateError) throw new Error(imageUpdateError.message);
+ imagePatches.push({
+ id: image.id,
+ caption: classification.caption || image.caption,
+ image_type: classification.image_type,
+ searchable: nextSearchable,
+ clinical_relevance_score: nextSearchable ? finalAssessment.clinical_relevance_score : 0,
+ skip_reason: nextSearchable ? null : classifiedSkip,
+ image_hash: imageHash,
+ perceptual_hash: perceptualHash,
+ labels: classification.labels,
+ metadata: {
+ clinical_use_class: finalAssessment.clinical_use_class,
+ clinical_use_reason: finalAssessment.clinical_use_reason,
+ clinical_signal_score: finalAssessment.clinical_signal_score,
+ admin_signal_score: finalAssessment.admin_signal_score,
+ image_policy_version: clinicalImagePolicyVersion,
+ retained_for_audit: retainAsAuditTable || undefined,
+ retained_for_document_view: retainAsAuditTable || undefined,
+ },
+ });
if (!nextSearchable) skipped += 1;
else searchable += 1;
}
+ await replaceMaintenanceArtifacts({
+ supabase,
+ document,
+ operation: "images",
+ payload: {
+ image_patches: imagePatches,
+ image_count: searchable,
+ metadata_patch: {
+ searchable_image_count: searchable,
+ skipped_image_count: skipped,
+ image_enriched_at: new Date().toISOString(),
+ },
+ },
+ });
return { searchable, skipped, total: images?.length ?? 0 };
}
async function stampExistingEnrichmentVersion(args: {
supabase: SupabaseAdmin;
- document: { id: string; metadata?: unknown };
+ document: MaintenanceDocument;
coverage: { summary?: MetadataRow; labels: MetadataRow[] };
ragEnrichmentVersion: string;
}) {
@@ -419,28 +449,12 @@ async function stampExistingEnrichmentVersion(args: {
version_stamped_at: stampedAt,
};
- const { error: documentError } = await args.supabase
- .from("documents")
- .update({ metadata: { ...metadataRecord(args.document.metadata), ...marker } })
- .eq("id", args.document.id);
- if (documentError) throw new Error(documentError.message);
-
- if (args.coverage.summary) {
- const { error: summaryError } = await args.supabase
- .from("document_summaries")
- .update({ metadata: { ...metadataRecord(args.coverage.summary.metadata), ...marker } })
- .eq("document_id", args.document.id);
- if (summaryError) throw new Error(summaryError.message);
- }
-
- for (const label of args.coverage.labels.filter((item) => item.source === "generated")) {
- if (!label.id) continue;
- const { error: labelError } = await args.supabase
- .from("document_labels")
- .update({ metadata: { ...metadataRecord(label.metadata), ...marker } })
- .eq("id", label.id);
- if (labelError) throw new Error(labelError.message);
- }
+ await replaceMaintenanceArtifacts({
+ supabase: args.supabase,
+ document: args.document,
+ operation: "stamp",
+ payload: { metadata_patch: marker },
+ });
}
async function main() {
@@ -527,6 +541,7 @@ async function main() {
while (attempts < maxAttempts && !success) {
try {
+ assertLegacyMaintenanceSnapshot(document);
if (args.mode === "metadata-stamp") {
if (!documentCoverage?.summary || documentCoverage.labels.every((label) => label.source !== "generated")) {
console.log(`SKIP cannot metadata-stamp missing enrichment: ${document.file_name}`);
@@ -543,27 +558,12 @@ async function main() {
return;
}
- let imageMetadata = metadataRecord(document.metadata);
let imageStats = { searchable: 0, skipped: 0, total: 0 };
if (args.mode === "summaries-labels-images") {
- imageStats = await classifyExistingImages(supabase, document.id);
- imageMetadata = {
- ...imageMetadata,
- searchable_image_count: imageStats.searchable,
- skipped_image_count: imageStats.skipped,
- image_enriched_at: new Date().toISOString(),
- };
- const { error: imageCountError } = await supabase
- .from("documents")
- .update({
- image_count: imageStats.searchable,
- metadata: imageMetadata as Json,
- })
- .eq("id", document.id);
- if (imageCountError) throw new Error(imageCountError.message);
+ imageStats = await classifyExistingImages(supabase, document);
}
- const evidence = await loadEvidence(supabase, document.id);
+ const evidence = await loadEvidence(supabase, document);
if (evidence.chunks.length === 0) {
console.log(`SKIP no chunks: ${document.file_name}`);
return;
@@ -573,7 +573,7 @@ async function main() {
if (args.mode === "summaries-labels-images") {
const enrichment = await upsertDocumentEnrichment({
supabase,
- document: { ...document, metadata: imageMetadata },
+ document,
chunks: evidence.chunks,
images: evidence.images,
});
@@ -590,31 +590,16 @@ async function main() {
}
const deepMemory = await upsertDocumentDeepMemory({
supabase,
- document: { ...document, metadata: imageMetadata },
+ document,
chunks: evidence.chunks as unknown as Parameters[0]["chunks"],
images: evidence.images as unknown as Parameters[0]["images"],
summary: enrichmentSummary,
});
- // A failed read would leave latestMetadata empty, and the write below would then replace
- // the document's whole metadata with only the status fields.
- const { data: latestDoc, error: latestDocError } = await supabase
- .from("documents")
- .select("metadata")
- .eq("id", document.id)
- .single();
- if (latestDocError) throw new Error(latestDocError.message);
- const latestMetadata = metadataRecord(latestDoc?.metadata);
- const { error: statusError } = await supabase
- .from("documents")
- .update({
- metadata: {
- ...latestMetadata,
- enrichment_status: "completed",
- enrichment_error: null,
- },
- })
- .eq("id", document.id);
- if (statusError) throw new Error(statusError.message);
+ await patchMaintenanceDocument({
+ supabase,
+ document,
+ patch: { enrichment_status: "completed", enrichment_error: null },
+ });
completed += 1;
console.log(
diff --git a/scripts/import-documents.ts b/scripts/import-documents.ts
index 02b156251a..cd040e9202 100644
--- a/scripts/import-documents.ts
+++ b/scripts/import-documents.ts
@@ -1,17 +1,13 @@
-import { readFile } from "node:fs/promises";
import path from "node:path";
import { loadEnvConfig } from "@next/env";
import {
- buildImportStoragePath,
chunkImportFiles,
- createDocumentId,
formatExactDuplicateSkip,
- importMimeType,
type ExistingImportDocument,
parseImportCliArgs,
scanImportFiles,
} from "@/lib/bulk-import";
-import { planDocumentName } from "@/lib/document-naming";
+import { assertExistingImportDocument, queueExistingImportDocument } from "./lib/import-document-queue";
import { assertSupabaseHealthy, probeSupabaseHealth } from "@/lib/supabase/health";
import { findOwnerIdByEmail } from "./lib/find-owner-id-by-email";
@@ -154,15 +150,15 @@ async function main() {
const duplicate = existing.get(file.contentHash);
console.log(
duplicate
- ? formatExactDuplicateSkip(file, duplicate, { dryRun: true })
- : `DRY RUN ${file.relativePath} ${file.contentHash.slice(0, 12)}`,
+ ? `DRY RUN EXISTING ${file.relativePath}: ${args.force ? "would request a generation-preserving replacement" : "would preserve indexed copies or repair missing queue work"}`
+ : `DRY RUN BLOCKED NEW INTAKE ${file.relativePath}: reviewed backend authority/admission is unavailable`,
);
}
if (files.length > args.queueBatchSize)
console.log(`DRY RUN omitted ${files.length - args.queueBatchSize} additional file(s).`);
if (dryRunOwnerId || args.ownerEmail) {
console.log(
- `DRY RUN duplicate check: would_queue=${files.length - exactCopyCount}, exact_copies=${exactCopyCount}, total=${files.length}`,
+ `DRY RUN duplicate check: blocked_new_intake=${files.length - exactCopyCount}, existing_copies_to_reconcile=${exactCopyCount}, total=${files.length}`,
);
}
return;
@@ -201,40 +197,6 @@ async function main() {
Array.from(new Set(files.map((file) => file.contentHash))),
);
- console.log("Pre-loading existing documents for name planning...");
- const { data: dbDocs, error: dbDocsErr } = await supabase
- .from("documents")
- .select("id,title,file_name,content_hash,metadata")
- .eq("owner_id", ownerId)
- .limit(5000);
- if (dbDocsErr) throw new Error(dbDocsErr.message);
- type ExistingDocForPlan = {
- id: string;
- title: string;
- file_name: string | null;
- content_hash: string | null;
- metadata?: unknown;
- };
- const existingDocsForPlanning: ExistingDocForPlan[] = [];
- for (const row of dbDocs ?? []) {
- const rawId = (row as { id?: unknown }).id;
- const rawTitle = (row as { title?: unknown }).title;
- const rawFileName = (row as { file_name?: unknown }).file_name;
- const rawContentHash = (row as { content_hash?: unknown }).content_hash;
- const id = typeof rawId === "string" ? rawId : "";
- const title = typeof rawTitle === "string" ? rawTitle : "";
- if (!id || !title) continue;
- const file_name = typeof rawFileName === "string" ? rawFileName : null;
- const content_hash = typeof rawContentHash === "string" ? rawContentHash : null;
- existingDocsForPlanning.push({
- id,
- title,
- file_name,
- content_hash,
- metadata: (row as { metadata?: unknown }).metadata,
- });
- }
-
let queued = 0;
let skipped = 0;
let failed = 0;
@@ -250,130 +212,26 @@ async function main() {
for (const file of fileBatch) {
try {
const duplicate = existing.get(file.contentHash);
- if (duplicate && !args.force) {
+ assertExistingImportDocument(duplicate);
+ const result = await queueExistingImportDocument({
+ rpc: (name, payload) => supabase.rpc(name, payload),
+ documentId: duplicate.id,
+ ownerId,
+ contentHash: file.contentHash,
+ batchId: batch.id,
+ force: args.force,
+ maxAttempts: env.WORKER_MAX_ATTEMPTS,
+ });
+ if (result.outcome === "skipped") {
skipped += 1;
console.log(formatExactDuplicateSkip(file, duplicate));
- continue;
- }
-
- if (duplicate && args.force) {
- const { error: resetError } = await supabase.rpc("reset_document_index", { p_document_id: duplicate.id });
- if (resetError) throw new Error(resetError.message);
- const { error: requeueError } = await supabase
- .from("documents")
- .update({
- status: "queued",
- error_message: null,
- page_count: 0,
- chunk_count: 0,
- image_count: 0,
- source_path: file.absolutePath,
- import_batch_id: batch.id,
- })
- .eq("id", duplicate.id);
- if (requeueError) throw new Error(requeueError.message);
- const { error: jobError } = await supabase.from("ingestion_jobs").insert({
- document_id: duplicate.id,
- batch_id: batch.id,
- status: "pending",
- stage: "queued",
- progress: 0,
- max_attempts: env.WORKER_MAX_ATTEMPTS,
- });
- if (jobError) throw new Error(jobError.message);
+ } else if (result.outcome === "already_active") {
+ skipped += 1;
+ console.log(`ALREADY QUEUED ${file.relativePath}`);
+ } else {
queued += 1;
- console.log(`REQUEUE ${file.relativePath}`);
- continue;
+ console.log(`${args.force ? "REQUEUE" : "REPAIR QUEUE"} ${file.relativePath}`);
}
-
- const documentId = createDocumentId();
- const storagePath = buildImportStoragePath(ownerId, documentId, file.fileName);
- const fileType = importMimeType(file.fileName);
- const namePlan = await planDocumentName({
- ownerId,
- fileName: file.fileName,
- requestedTitle: null,
- contentHash: file.contentHash,
- existingDocs: existingDocsForPlanning,
- });
- const upload = await supabase.storage
- .from(env.SUPABASE_DOCUMENT_BUCKET)
- .upload(storagePath, await readFile(file.absolutePath), {
- contentType: fileType,
- upsert: false,
- });
- if (upload.error) throw new Error(upload.error.message);
-
- const { error: documentError } = await supabase.from("documents").insert({
- id: documentId,
- owner_id: ownerId,
- title: namePlan.title,
- description: null,
- file_name: file.fileName,
- file_type: fileType,
- file_size: file.size,
- storage_path: storagePath,
- content_hash: file.contentHash,
- source_path: file.absolutePath,
- import_batch_id: batch.id,
- status: "queued",
- metadata: {
- source_title: namePlan.title,
- publisher: null,
- jurisdiction: "Australia/WA",
- version: null,
- publication_date: null,
- review_date: null,
- uploaded_at: new Date().toISOString(),
- indexed_at: null,
- uploaded_by: ownerId,
- original_file_name: namePlan.originalFileName,
- original_title: namePlan.originalTitle,
- smart_title_base: namePlan.baseTitle,
- smart_title_group_key: namePlan.duplicateGroupKey,
- smart_title_duplicate_index: namePlan.duplicateIndex,
- smart_title_duplicate_reason: namePlan.duplicateReason,
- document_status: "unknown",
- clinical_validation_status: "unverified",
- extraction_quality: "unknown",
- confidentiality_scope: "guidelines-only",
- source_path: file.absolutePath,
- content_hash: file.contentHash,
- import_batch_id: batch.id,
- queue_batch_size: args.queueBatchSize,
- },
- });
- if (documentError) throw new Error(documentError.message);
-
- const { error: jobError } = await supabase.from("ingestion_jobs").insert({
- document_id: documentId,
- batch_id: batch.id,
- status: "pending",
- stage: "queued",
- progress: 0,
- max_attempts: env.WORKER_MAX_ATTEMPTS,
- });
- if (jobError) throw new Error(jobError.message);
-
- existingDocsForPlanning.push({
- id: documentId,
- title: namePlan.title,
- file_name: file.fileName,
- content_hash: file.contentHash,
- metadata: {
- smart_title_group_key: namePlan.duplicateGroupKey,
- },
- });
-
- existing.set(file.contentHash, {
- id: documentId,
- storage_path: storagePath,
- title: namePlan.title,
- source_path: file.absolutePath,
- content_hash: file.contentHash,
- });
- queued += 1;
- console.log(`QUEUE ${file.relativePath}`);
} catch (error) {
failed += 1;
console.error(`FAIL ${file.relativePath}`, error instanceof Error ? error.message : String(error));
diff --git a/scripts/lib/daily-health-summary.mjs b/scripts/lib/daily-health-summary.mjs
index a8614f4722..49ae7237c0 100644
--- a/scripts/lib/daily-health-summary.mjs
+++ b/scripts/lib/daily-health-summary.mjs
@@ -246,7 +246,11 @@ export function assessSource(
{ latestRun = null, issue = null, recentMarkers = null, botActivityAt = null, clearingRun = null, now },
) {
const nowMs = time(now) ?? Date.now();
- const link = issueLink(issue);
+ const runLink =
+ typeof latestRun?.html_url === "string" && /^https:\/\/[^\s()]+$/.test(latestRun.html_url)
+ ? `[run](${latestRun.html_url})`
+ : "";
+ const link = issueLink(issue) || runLink;
const runAt = time(latestRun?.updated_at ?? latestRun?.run_started_at ?? latestRun?.created_at);
const stale = source.maxAgeHours !== null && (runAt === null || nowMs - runAt > source.maxAgeHours * HOUR);
const staleText =
@@ -299,6 +303,17 @@ export function assessSource(
}
}
+ // Freshness proves only that the detector attempted a run. Failures (including a failed
+ // issue write), cancellations, skips and unknown conclusions cannot establish service health.
+ // Preserve intentionally inert/event-driven no-run defaults and any stronger service alert.
+ if (latestRun && latestRun.conclusion !== "success") {
+ detail =
+ status === "green"
+ ? "latest monitoring run did not succeed; health is unassessed"
+ : `latest monitoring run did not succeed; last known: ${detail}`;
+ if (status === "green") status = "amber";
+ }
+
if (stale) {
detail = status === "green" ? staleText : `${staleText}; last known: ${detail}`;
status = "red";
diff --git a/scripts/lib/import-document-queue.ts b/scripts/lib/import-document-queue.ts
new file mode 100644
index 0000000000..b041a6ab15
--- /dev/null
+++ b/scripts/lib/import-document-queue.ts
@@ -0,0 +1,49 @@
+type ImportQueuePayload = {
+ p_document_id: string;
+ p_owner_id: string;
+ p_content_hash: string;
+ p_batch_id: string;
+ p_force: boolean;
+ p_max_attempts: number;
+};
+
+/** Existing bytes only: this operation cannot create a document or grant source/provider authority. */
+export async function queueExistingImportDocument(args: {
+ rpc: (
+ name: "enqueue_import_document",
+ payload: ImportQueuePayload,
+ ) => PromiseLike<{ data: unknown; error: { message: string } | null }>;
+ documentId: string;
+ ownerId: string;
+ contentHash: string;
+ batchId: string;
+ force: boolean;
+ maxAttempts: number;
+}) {
+ const { data, error } = await args.rpc("enqueue_import_document", {
+ p_document_id: args.documentId,
+ p_owner_id: args.ownerId,
+ p_content_hash: args.contentHash,
+ p_batch_id: args.batchId,
+ p_force: args.force,
+ p_max_attempts: args.maxAttempts,
+ });
+ if (error) throw new Error(error.message);
+ if (
+ !data ||
+ typeof data !== "object" ||
+ !("outcome" in data) ||
+ !["enqueued", "already_active", "skipped"].includes(String(data.outcome))
+ ) {
+ throw new Error("Import enqueue returned an invalid result; retain existing bytes and retry by exact identity.");
+ }
+ return data as { outcome: "enqueued" | "already_active" | "skipped" };
+}
+
+export function assertExistingImportDocument(document: unknown): asserts document {
+ if (!document) {
+ throw new Error(
+ "New backend intake requires a reviewed source-authority manifest and an implemented authenticated backend admission RPC. No bytes were uploaded. Use the administrator upload path for its supported formats; do not bypass its format, scanner or supplier gates.",
+ );
+ }
+}
diff --git a/scripts/lib/tenancy-scan.mjs b/scripts/lib/tenancy-scan.mjs
index 198cd4cc7f..c16d2fcc7c 100644
--- a/scripts/lib/tenancy-scan.mjs
+++ b/scripts/lib/tenancy-scan.mjs
@@ -2014,9 +2014,11 @@ export const UNTIERED_TABLE_DECLARATIONS = [
file: "src/lib/work-roles/people.ts",
table: "work_hospitals",
fn: "applyPeopleChange",
- queries: 1,
+ queries: 2,
proof: PROOF_KINDS.UNTIERED_TABLE,
- reason: WORK_HOSPITAL_STRUCTURE_REASON,
+ reason:
+ WORK_HOSPITAL_STRUCTURE_REASON +
+ " The second query is the administrator-only link-team preflight: exact destination hospital id and unarchived state, repeated under a shared row lock by work_guard_live_hospital_team before the link is written.",
},
{
file: "src/lib/work-roles/server.ts",
diff --git a/scripts/reconcile-registry-governance.ts b/scripts/reconcile-registry-governance.ts
index 9bebd730a7..e8bd898e06 100644
--- a/scripts/reconcile-registry-governance.ts
+++ b/scripts/reconcile-registry-governance.ts
@@ -1,3 +1,4 @@
+import { replaceMaintenanceArtifacts } from "@/lib/ingestion-maintenance";
import { loadEnvConfig } from "@next/env";
import type { DifferentialRecordRow } from "@/lib/differential-records";
@@ -33,6 +34,7 @@ export type RegistryGovernanceDocument = {
id: string;
owner_id: string | null;
metadata: Json | null;
+ updated_at?: string;
};
export type RegistryGovernanceLabel = {
@@ -50,11 +52,19 @@ type RegistryGovernanceDocumentUpdate = {
id: string;
ownerId: string | null;
metadata: Record;
+ expectedMetadata: Json | null;
+ expectedUpdatedAt?: string;
};
export type RegistryGovernancePlan = {
ownerId: string;
projections: RegistryGovernanceProjection[];
+ mutations: Array<{
+ document: RegistryGovernanceDocument;
+ metadataPatch: Record;
+ labelIdsToDelete: string[];
+ labelsToUpsert: TablesInsert<"document_labels">[];
+ }>;
documentUpdates: RegistryGovernanceDocumentUpdate[];
labelsToInsert: TablesInsert<"document_labels">[];
labelsToUpdate: TablesInsert<"document_labels">[];
@@ -241,7 +251,13 @@ export function buildRegistryGovernancePlan(args: {
const document = documentById.get(projection.documentId)!;
const mergedMetadata = { ...metadataRecord(document.metadata), ...projection.requiredMetadata };
if (stableJson(mergedMetadata) !== stableJson(metadataRecord(document.metadata))) {
- documentUpdates.push({ id: document.id, ownerId: document.owner_id, metadata: mergedMetadata });
+ documentUpdates.push({
+ id: document.id,
+ ownerId: document.owner_id,
+ metadata: mergedMetadata,
+ expectedMetadata: document.metadata,
+ expectedUpdatedAt: document.updated_at,
+ });
}
const documentLabels = labelsByDocument.get(projection.documentId) ?? [];
@@ -272,9 +288,23 @@ export function buildRegistryGovernancePlan(args: {
else if (!expectedLabelCurrent(expectedLabel, intentLabel)) labelsToUpdate.push(intentLabel);
}
+ const metadataChanged = new Set(documentUpdates.map((update) => update.id));
+ const deletionIds = new Set(labelIdsToDelete);
+ const mutations = args.projections.flatMap((projection) => {
+ const document = documentById.get(projection.documentId)!;
+ const deletions = (labelsByDocument.get(document.id) ?? [])
+ .filter((label) => deletionIds.has(label.id))
+ .map((label) => label.id);
+ const upserts = [...labelsToInsert, ...labelsToUpdate].filter((label) => label.document_id === document.id);
+ return metadataChanged.has(document.id) || deletions.length || upserts.length
+ ? [{ document, metadataPatch: projection.requiredMetadata, labelIdsToDelete: deletions, labelsToUpsert: upserts }]
+ : [];
+ });
+
return {
ownerId,
projections: args.projections,
+ mutations,
documentUpdates,
labelsToInsert,
labelsToUpdate,
@@ -403,7 +433,7 @@ async function loadExistingGovernance(supabase: AdminClient, documentIds: string
const labels: RegistryGovernanceLabel[] = [];
for (const documentIdBatch of chunks(documentIds, READ_BATCH_SIZE)) {
const [documentResult, labelResult] = await Promise.all([
- supabase.from("documents").select("id,owner_id,metadata").in("id", documentIdBatch),
+ supabase.from("documents").select("id,owner_id,metadata,updated_at").in("id", documentIdBatch),
supabase
.from("document_labels")
.select("id,document_id,owner_id,label,label_type,source,confidence,metadata")
@@ -428,26 +458,24 @@ async function runWithConcurrency(items: T[], concurrency: number, worker: (i
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => runWorker()));
}
-async function applyPlan(supabase: AdminClient, plan: RegistryGovernancePlan) {
- await runWithConcurrency(plan.documentUpdates, WRITE_CONCURRENCY, async (update) => {
- const updateQuery = supabase.from("documents").update({ metadata: update.metadata }).eq("id", update.id);
- const scopedUpdate =
- update.ownerId === null ? updateQuery.is("owner_id", null) : updateQuery.eq("owner_id", update.ownerId);
- const { data, error } = await scopedUpdate.select("id");
- if (error) throw new Error(`Registry metadata update failed for ${update.id}: ${error.message}`);
- if (data?.length !== 1) throw new Error(`Registry metadata update did not match exactly one row for ${update.id}.`);
+export async function applyRegistryGovernancePlan(supabase: AdminClient, plan: RegistryGovernancePlan) {
+ await runWithConcurrency(plan.mutations, WRITE_CONCURRENCY, async (mutation) => {
+ await replaceMaintenanceArtifacts({
+ supabase,
+ document: { ...mutation.document, updated_at: mutation.document.updated_at ?? "" },
+ operation: "registry",
+ payload: {
+ metadata_patch: mutation.metadataPatch,
+ label_ids_to_delete: mutation.labelIdsToDelete,
+ labels_to_upsert: mutation.labelsToUpsert.map((label) => ({
+ label: label.label,
+ label_type: label.label_type,
+ confidence: label.confidence,
+ metadata: label.metadata,
+ })),
+ },
+ });
});
-
- for (const labelIdBatch of chunks(plan.labelIdsToDelete, READ_BATCH_SIZE)) {
- const { error } = await supabase.from("document_labels").delete().in("id", labelIdBatch);
- if (error) throw new Error(`Registry generated-label cleanup failed: ${error.message}`);
- }
- for (const labelBatch of chunks([...plan.labelsToInsert, ...plan.labelsToUpdate], READ_BATCH_SIZE)) {
- const { error } = await supabase
- .from("document_labels")
- .upsert(labelBatch, { onConflict: "document_id,label_type,label,source" });
- if (error) throw new Error(`Registry intent-label upsert failed: ${error.message}`);
- }
}
function reportForPlan(plan: RegistryGovernancePlan, write: boolean) {
@@ -486,7 +514,7 @@ async function main() {
const plan = buildRegistryGovernancePlan({ ...existing, projections, expectedOwnerId: ownerId });
const report = reportForPlan(plan, args.write);
- if (args.write) await applyPlan(supabase, plan);
+ if (args.write) await applyRegistryGovernancePlan(supabase, plan);
if (args.json) console.log(JSON.stringify(report, null, 2));
else {
console.log("[registry:reconcile-governance]");
diff --git a/scripts/verify-storage-restore.mjs b/scripts/verify-storage-restore.mjs
new file mode 100644
index 0000000000..2922aa3079
--- /dev/null
+++ b/scripts/verify-storage-restore.mjs
@@ -0,0 +1,203 @@
+#!/usr/bin/env node
+// Offline byte acceptance for an explicitly selected, privately held Storage download.
+// This tool never contacts Storage, loads environment files, or restores anything.
+import { constants } from "node:fs";
+import { lstat, open, readFile, readdir, realpath, stat, writeFile } from "node:fs/promises";
+import { createHash } from "node:crypto";
+import path from "node:path";
+import { isDirectEntrypoint } from "./lib/is-entrypoint.mjs";
+
+export const STORAGE_RESTORE_BUCKETS = ["clinical-documents", "clinical-images", "cme-private-evidence"];
+const MAX_OBJECTS = 100_000;
+const MAX_OBJECT_BYTES = 512 * 1024 * 1024;
+const MAX_MANIFEST_BYTES = 32 * 1024 * 1024;
+
+function validObjectPath(value) {
+ return (
+ typeof value === "string" &&
+ value.length > 0 &&
+ value.length <= 4096 &&
+ !/[\\\x00-\x1f\x7f]/.test(value) &&
+ value.split("/").every((part) => part && part !== "." && part !== "..")
+ );
+}
+
+export function validateStorageManifest(manifest) {
+ if (
+ manifest?.schemaVersion !== 1 ||
+ manifest.algorithm !== "sha256" ||
+ !Array.isArray(manifest.objects) ||
+ manifest.objects.length === 0 ||
+ manifest.objects.length > MAX_OBJECTS
+ ) {
+ throw new Error("Invalid or empty Storage byte manifest");
+ }
+ const seen = new Set();
+ for (const [index, object] of manifest.objects.entries()) {
+ if (
+ !STORAGE_RESTORE_BUCKETS.includes(object?.bucket) ||
+ !validObjectPath(object.path) ||
+ !Number.isSafeInteger(object.size) ||
+ object.size < 0 ||
+ object.size > MAX_OBJECT_BYTES ||
+ typeof object.sha256 !== "string" ||
+ !/^[a-f0-9]{64}$/.test(object.sha256)
+ ) {
+ throw new Error(`Invalid Storage byte manifest object ${index + 1}`);
+ }
+ const key = `${object.bucket}/${object.path}`;
+ if (seen.has(key)) throw new Error(`Duplicate Storage byte manifest object ${index + 1}`);
+ seen.add(key);
+ }
+ return manifest;
+}
+
+async function selectedRoot(root) {
+ const canonical = await realpath(path.resolve(root));
+ if (!(await stat(canonical)).isDirectory()) throw new Error("Storage root must be a directory");
+ return canonical;
+}
+
+async function inventory(root) {
+ const entries = [];
+ const top = await readdir(root, { withFileTypes: true });
+ if (
+ top.length !== STORAGE_RESTORE_BUCKETS.length ||
+ top.some((entry) => !STORAGE_RESTORE_BUCKETS.includes(entry.name) || !entry.isDirectory())
+ ) {
+ throw new Error("Storage root must contain exactly the three supported bucket directories");
+ }
+ async function visit(bucket, prefix = "") {
+ for (const entry of await readdir(path.join(root, bucket, prefix), { withFileTypes: true })) {
+ const name = prefix ? `${prefix}/${entry.name}` : entry.name;
+ if (!validObjectPath(name)) throw new Error("Unsupported Storage object path");
+ if (entry.isDirectory()) await visit(bucket, name);
+ else if (entry.isFile()) {
+ entries.push({ bucket, path: name });
+ if (entries.length > MAX_OBJECTS) throw new Error("Storage object inventory exceeds safety bound");
+ } else throw new Error("Storage object inventory contains a symlink or nonregular file");
+ }
+ }
+ for (const bucket of STORAGE_RESTORE_BUCKETS) await visit(bucket);
+ return entries.sort((a, b) => `${a.bucket}/${a.path}`.localeCompare(`${b.bucket}/${b.path}`));
+}
+
+async function objectBytes(root, object) {
+ // Reject symlinks in every component, including bucket directories. The final open also
+ // refuses a symlink swap. The caller owns this isolated fixture/download root.
+ let full = root;
+ for (const component of [object.bucket, ...object.path.split("/")]) {
+ full = path.join(full, component);
+ if ((await lstat(full)).isSymbolicLink()) throw new Error("symlink");
+ }
+ if (!(await lstat(full)).isFile()) throw new Error("nonregular");
+ const file = await open(full, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
+ try {
+ const info = await file.stat();
+ if (!info.isFile() || info.size > MAX_OBJECT_BYTES) throw new Error("nonregular_or_oversize");
+ const hash = createHash("sha256");
+ let size = 0;
+ for await (const bytes of file.createReadStream({ autoClose: false })) {
+ size += bytes.length;
+ if (size > MAX_OBJECT_BYTES) throw new Error("oversize");
+ hash.update(bytes);
+ }
+ if (size !== info.size) throw new Error("changed_during_read");
+ return { size, sha256: hash.digest("hex") };
+ } finally {
+ await file.close();
+ }
+}
+
+export async function createStorageManifest(root) {
+ const canonical = await selectedRoot(root);
+ const objects = [];
+ for (const object of await inventory(canonical))
+ objects.push({ ...object, ...(await objectBytes(canonical, object)) });
+ return validateStorageManifest({ schemaVersion: 1, algorithm: "sha256", objects });
+}
+
+export async function verifyStorageRestore(root, manifest) {
+ validateStorageManifest(manifest);
+ const canonical = await selectedRoot(root);
+ const failures = [];
+ let verified = 0;
+ let bytes = 0;
+ for (const [index, object] of manifest.objects.entries()) {
+ try {
+ const actual = await objectBytes(canonical, object);
+ if (actual.size !== object.size || actual.sha256 !== object.sha256) {
+ failures.push({ object: index + 1, reason: "size_or_digest_mismatch" });
+ } else {
+ verified += 1;
+ bytes += actual.size;
+ }
+ } catch {
+ // Do not print confidential object paths or native filesystem error text.
+ failures.push({ object: index + 1, reason: "missing_or_unreadable_bytes" });
+ }
+ }
+ try {
+ const expected = new Set(manifest.objects.map((object) => `${object.bucket}/${object.path}`));
+ const actual = await inventory(canonical);
+ if (actual.some((object) => !expected.has(`${object.bucket}/${object.path}`))) {
+ failures.push({ reason: "unexpected_objects" });
+ }
+ } catch {
+ failures.push({ reason: "invalid_bucket_inventory" });
+ }
+ return { ok: failures.length === 0, expected: manifest.objects.length, verified, bytes, failures };
+}
+
+export async function runStorageRestoreCli(args) {
+ const [mode, ...flags] = args;
+ const options = {};
+ if (!["manifest", "verify"].includes(mode) || flags.length !== 4)
+ throw new Error(
+ "Usage: manifest --root --output OR verify --root --manifest ",
+ );
+ for (let index = 0; index < flags.length; index += 2) {
+ const flag = flags[index];
+ if (
+ !["--root", mode === "manifest" ? "--output" : "--manifest"].includes(flag) ||
+ options[flag] ||
+ !flags[index + 1]
+ )
+ throw new Error("Invalid or repeated Storage verifier option");
+ options[flag] = flags[index + 1];
+ }
+ if (!options["--root"]) throw new Error("Explicit Storage root is required");
+ if (mode === "manifest") {
+ const manifest = await createStorageManifest(options["--root"]);
+ const encoded = `${JSON.stringify(manifest, null, 2)}\n`;
+ if (Buffer.byteLength(encoded) > MAX_MANIFEST_BYTES) throw new Error("Storage manifest exceeds safety bound");
+ // Refuse to overwrite a known-good baseline with restored or partial bytes.
+ await writeFile(options["--output"], encoded, { flag: "wx", mode: 0o600 });
+ return {
+ ok: true,
+ objects: manifest.objects.length,
+ manifestSha256: createHash("sha256").update(encoded).digest("hex"),
+ };
+ }
+ if ((await stat(options["--manifest"])).size > MAX_MANIFEST_BYTES)
+ throw new Error("Storage manifest exceeds safety bound");
+ const encoded = await readFile(options["--manifest"], "utf8");
+ return {
+ ...(await verifyStorageRestore(options["--root"], JSON.parse(encoded))),
+ manifestSha256: createHash("sha256").update(encoded).digest("hex"),
+ };
+}
+
+if (isDirectEntrypoint(import.meta.url)) {
+ runStorageRestoreCli(process.argv.slice(2))
+ .then((result) => {
+ console.log(JSON.stringify(result));
+ process.exitCode = result.ok ? 0 : 1;
+ })
+ .catch(() => {
+ console.error(
+ "Storage byte acceptance failed: invalid input or unreadable bytes; keep private paths out of shared logs.",
+ );
+ process.exitCode = 1;
+ });
+}
diff --git a/src/app/api/cme/entries/[id]/route.ts b/src/app/api/cme/entries/[id]/route.ts
index 6adde4c53d..17c007cac5 100644
--- a/src/app/api/cme/entries/[id]/route.ts
+++ b/src/app/api/cme/entries/[id]/route.ts
@@ -117,6 +117,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
sourceUrl: fields.sourceUrl,
buckets: fields.buckets,
formalPeerReviewHours: fields.formalPeerReviewHours,
+ version: fields.expectedVersion,
},
amendmentReason,
);
@@ -170,6 +171,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
sourceUrl: body.sourceUrl === undefined ? (existingRow.source_url ?? null) : body.sourceUrl,
buckets: body.buckets,
formalPeerReviewHours: body.formalPeerReviewHours,
+ version: body.expectedVersion,
};
const saved = await saveCmeEntry(supabase, user.id, yearRow.id, entry);
return NextResponse.json({ entry: saved });
diff --git a/src/app/api/on-call/entries/[id]/verify/route.ts b/src/app/api/on-call/entries/[id]/verify/route.ts
index ac0f956559..357e27bfd4 100644
--- a/src/app/api/on-call/entries/[id]/verify/route.ts
+++ b/src/app/api/on-call/entries/[id]/verify/route.ts
@@ -12,11 +12,13 @@ import { rowToOnCallEntry } from "@/lib/on-call/repository";
import { mayPublishSharedOnCallEntry, sharedOnCallEntryRefusedResponse } from "@/lib/on-call/shared-entry-publisher";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";
+import { readBoundedJson } from "@/lib/validation/body";
import { parseRouteParams } from "@/lib/validation/params";
export const runtime = "nodejs";
const onCallEntryRouteParamsSchema = z.object({ id: z.string().uuid() });
+const verificationPreconditionSchema = z.object({ expectedUpdatedAt: z.string().datetime({ offset: true }) }).strict();
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
@@ -44,6 +46,15 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
return rateLimitJsonResponse("On Call requests are rate limited. Try again shortly.", rateLimit);
}
+ const rawPrecondition = await readBoundedJson(request, 512);
+ if (!rawPrecondition || typeof rawPrecondition !== "object" || !("expectedUpdatedAt" in rawPrecondition)) {
+ return publicErrorResponse("Reload this entry before confirming it.", 428, {
+ code: "on_call_verification_precondition_required",
+ });
+ }
+ const precondition = verificationPreconditionSchema.safeParse(rawPrecondition);
+ if (!precondition.success) return publicErrorResponse("Invalid verification precondition.", 400);
+
// A shared entry's checked date is shown to every signed-in user, so only an administrator
// may stamp one (owner decision, 25 Oct 2026); anyone else verifies their private entries.
const mayVerifyShared = await mayPublishSharedOnCallEntry(request, supabase, user);
@@ -56,11 +67,14 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
.update({ last_verified_at: new Date().toISOString() })
.eq("id", id)
.eq("owner_id", user.id)
+ // One atomic UPDATE predicate: an edit between the reviewed read and this
+ // write must not receive a verification stamp for unseen content.
+ .eq("updated_at", precondition.data.expectedUpdatedAt)
.in("is_personal", verifiable)
.select("*")
.maybeSingle();
if (error) throw new Error(error.message);
- if (!data && !mayVerifyShared) {
+ if (!data) {
// Say why when the caller's own entry is a shared one, rather than calling it missing.
const { data: own, error: ownError } = await supabase
.from("on_call_entries")
@@ -69,9 +83,16 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
.eq("owner_id", user.id)
.maybeSingle();
if (ownError) throw new Error(ownError.message);
- if ((own as { is_personal?: unknown } | null)?.is_personal === false) return sharedOnCallEntryRefusedResponse();
+ if (!mayVerifyShared && (own as { is_personal?: unknown } | null)?.is_personal === false)
+ return sharedOnCallEntryRefusedResponse();
+ if (own)
+ return publicErrorResponse(
+ "This entry changed while confirming. Reload it and review the latest content.",
+ 409,
+ { code: "on_call_entry_changed" },
+ );
+ return publicErrorResponse("On Call entry not found.", 404, { code: "on_call_entry_not_found" });
}
- if (!data) return publicErrorResponse("On Call entry not found.", 404, { code: "on_call_entry_not_found" });
return NextResponse.json({ entry: { ...rowToOnCallEntry(data as Record), isOwn: true } });
} catch (error) {
diff --git a/src/app/api/teaching/assessments/magic-links/route.ts b/src/app/api/teaching/assessments/magic-links/route.ts
index 2372602c70..b40857bbab 100644
--- a/src/app/api/teaching/assessments/magic-links/route.ts
+++ b/src/app/api/teaching/assessments/magic-links/route.ts
@@ -30,9 +30,13 @@ function magicLinkErrorResponse(error: unknown): Response {
}
export async function GET(request: Request) {
- return withTeachingApi(request, async (client, ownerId) => ({
- links: await new DurableEpaMagicLinkRepository(client).listForRegistrar(ownerId),
- }));
+ return withTeachingApi(request, async (client, ownerId) =>
+ new DurableEpaMagicLinkRepository(client).listForRegistrarPage(
+ ownerId,
+ new Date(),
+ new URL(request.url).searchParams.get("cursor"),
+ ),
+ );
}
export async function POST(request: Request) {
diff --git a/src/app/auth/confirm/route.ts b/src/app/auth/confirm/route.ts
index fbd83d3f8c..b28e834a0d 100644
--- a/src/app/auth/confirm/route.ts
+++ b/src/app/auth/confirm/route.ts
@@ -9,6 +9,7 @@ import {
sameOriginNext,
} from "@/lib/supabase/auth-return";
import { createSupabaseServerClient } from "@/lib/supabase/server";
+import { readBoundedFormData } from "@/lib/validation/body";
import { continuePage } from "./continue-page";
@@ -95,18 +96,31 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
const origin = callerOrigin(request);
- const form = await request.formData().catch(() => null);
+ // Reject before reading a body: the form is only posted by this site's
+ // Continue page, and no supplied destination is trusted before admission.
+ const reject = () =>
+ NextResponse.redirect(authFailureRedirect("/", origin, "verify_failed").headers.get("location")!, 303);
+ if (!isSameOriginPost(request, origin)) return reject();
+ if (
+ request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/x-www-form-urlencoded"
+ ) {
+ return reject();
+ }
+ const form = await readBoundedFormData(request, 0, { envelopeBytes: 16 * 1024 }).catch(() => null);
+ if (!form) return reject();
+ const limits: Record = { token_hash: 1024, type: 32, next: 4096 };
const params = new URLSearchParams();
- for (const name of ["token_hash", "type", "next"]) {
- const value = form?.get(name);
- if (typeof value === "string") params.set(name, value);
+ for (const [name, value] of form.entries()) {
+ const limit = limits[name];
+ if (!Object.hasOwn(limits, name) || typeof value !== "string" || value.length > limit || params.has(name))
+ return reject();
+ params.set(name, value);
}
const { tokenHash, type, next, failure } = readLink(params, origin);
// 303 so the browser follows with a GET, never re-posting the form.
const seeOther = (response: NextResponse) =>
NextResponse.redirect(response.headers.get("location") ?? `${origin}/`, 303);
- if (!isSameOriginPost(request, origin)) return seeOther(failure("verify_failed"));
if (!tokenHash || !type) return seeOther(failure("missing_auth_code"));
const { setAllCookies, withAuthMutations } = createAuthMutationCollector();
diff --git a/src/components/admin/admin-help-page.tsx b/src/components/admin/admin-help-page.tsx
index 194d715cd6..d8682e5f4a 100644
--- a/src/components/admin/admin-help-page.tsx
+++ b/src/components/admin/admin-help-page.tsx
@@ -1,5 +1,9 @@
"use client";
+import { OnCallEntrySessionBoundary } from "@/components/on-call/on-call-entry-session-boundary";
+
+import { peekOnCallEntrySessionEpoch } from "@/lib/on-call/entry-cache-keys";
+
import { Plus } from "lucide-react";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
@@ -71,7 +75,8 @@ function noSubscription() {
return () => {};
}
-export function AdminHelpPage({ now: nowProp }: { now?: Date } = {}) {
+function AdminHelpPageSession({ now: nowProp }: { now?: Date } = {}) {
+ const sessionEpoch = peekOnCallEntrySessionEpoch();
const { zone } = useWorkTimeZone();
const router = useRouter();
const { isAuthenticated } = useAccountData();
@@ -116,18 +121,23 @@ export function AdminHelpPage({ now: nowProp }: { now?: Date } = {}) {
}, [filtered]);
function upsertCachedEntry(entry: OnCallEntry) {
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
if (isOnCallExampleEntry(entry)) return;
// The latest cache, not this render's list: two saves in flight would otherwise undo each other.
const latest = readCachedOnCallEntries()?.entries ?? entries;
const next = latest.some((existing) => existing.id === entry.id)
? latest.map((existing) => (existing.id === entry.id ? entry : existing))
: [...latest, entry];
- cacheOnCallEntries(next);
+ cacheOnCallEntries(next, sessionEpoch);
}
function removeCachedEntry(id: string) {
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
const latest = readCachedOnCallEntries()?.entries ?? entries;
- cacheOnCallEntries(latest.filter((existing) => existing.id !== id));
+ cacheOnCallEntries(
+ latest.filter((existing) => existing.id !== id),
+ sessionEpoch,
+ );
}
function openNewEntry() {
@@ -273,3 +283,11 @@ function listInWords(words: readonly string[]): string {
if (words.length <= 1) return words.join("");
return `${words.slice(0, -1).join(", ")} and ${words[words.length - 1]}`;
}
+
+export function AdminHelpPage(props: Parameters[0] = {}) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/admin/admin-new-job-page.tsx b/src/components/admin/admin-new-job-page.tsx
index 8980333771..cb7aa270b3 100644
--- a/src/components/admin/admin-new-job-page.tsx
+++ b/src/components/admin/admin-new-job-page.tsx
@@ -1,5 +1,9 @@
"use client";
+import { OnCallEntrySessionBoundary } from "@/components/on-call/on-call-entry-session-boundary";
+
+import { peekOnCallEntrySessionEpoch } from "@/lib/on-call/entry-cache-keys";
+
import dynamic from "next/dynamic";
import { ClipboardList, Copy, FileText } from "lucide-react";
import { useMemo, useRef, useState } from "react";
@@ -76,7 +80,7 @@ async function patchEntry(id: string, body: unknown): Promise {
if (!response.ok) throw await parseApiErrorResponse(response);
const json: unknown = await response.json();
const parsed = onCallEntrySchema.safeParse((json as { entry?: unknown } | null)?.entry);
- if (!parsed.success) throw new Error("Save response was invalid.");
+ if (!parsed.success || parsed.data.id !== id) throw new Error("Save response was invalid.");
return parsed.data;
}
@@ -230,7 +234,8 @@ function PaperworkSignpost({ overview }: { readonly overview: ComplianceOverview
* "Your Admin records"). Order, not weeks, is what the page shows (owner
* decision) — there is no week strip.
*/
-export function AdminNewJobPage({ now: nowProp }: { now?: Date } = {}) {
+function AdminNewJobPageSession({ now: nowProp }: { now?: Date } = {}) {
+ const sessionEpoch = peekOnCallEntrySessionEpoch();
const { scope, isCurrent } = useAccountRequestScope();
const [owner, setOwner] = useState(scope);
const { isAuthenticated } = useAccountData();
@@ -288,6 +293,7 @@ export function AdminNewJobPage({ now: nowProp }: { now?: Date } = {}) {
: null;
function upsertCachedEntry(entry: OnCallEntry) {
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
if (!isCurrent()) return;
if (isOnCallExampleEntry(entry)) return;
// The latest cache, not this render's list: two saves in flight would otherwise undo each other.
@@ -295,13 +301,17 @@ export function AdminNewJobPage({ now: nowProp }: { now?: Date } = {}) {
const next = latest.some((existing) => existing.id === entry.id)
? latest.map((existing) => (existing.id === entry.id ? entry : existing))
: [...latest, entry];
- cacheOnCallEntries(next);
+ cacheOnCallEntries(next, sessionEpoch);
}
function removeCachedEntry(id: string) {
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
if (!isCurrent()) return;
const latest = readCachedOnCallEntries()?.entries ?? entries;
- cacheOnCallEntries(latest.filter((existing) => existing.id !== id));
+ cacheOnCallEntries(
+ latest.filter((existing) => existing.id !== id),
+ sessionEpoch,
+ );
}
async function handleToggle(entry: OnCallEntry, done: boolean) {
@@ -561,3 +571,11 @@ export function AdminNewJobPage({ now: nowProp }: { now?: Date } = {}) {
>
);
}
+
+export function AdminNewJobPage(props: Parameters[0] = {}) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/admin/admin-quick-add-sheet.tsx b/src/components/admin/admin-quick-add-sheet.tsx
index fc973d5f2c..9ddee010aa 100644
--- a/src/components/admin/admin-quick-add-sheet.tsx
+++ b/src/components/admin/admin-quick-add-sheet.tsx
@@ -1,5 +1,9 @@
"use client";
+import { OnCallEntrySessionBoundary } from "@/components/on-call/on-call-entry-session-boundary";
+
+import { peekOnCallEntrySessionEpoch } from "@/lib/on-call/entry-cache-keys";
+
import { useState } from "react";
import { focusRing } from "@/components/card-recipes";
@@ -47,7 +51,7 @@ function slugify(title: string): string {
* recent" lead-time chips (fill only, never preselected), and Save grey until
* both fields are filled. Creates a new, private compliance row.
*/
-export function AdminQuickAddSheet({
+function AdminQuickAddSheetSession({
open,
onClose,
onSaved,
@@ -60,6 +64,7 @@ export function AdminQuickAddSheet({
* first — shown as extra "your recent" chips ahead of the two defaults. */
recentLeadTimeDays?: readonly number[];
}) {
+ const sessionEpoch = peekOnCallEntrySessionEpoch();
const [name, setName] = useState("");
const [date, setDate] = useState("");
const [leadTimeDays, setLeadTimeDays] = useState(null);
@@ -73,6 +78,7 @@ export function AdminQuickAddSheet({
const chips = [...new Set([...recentLeadTimeDays, ...DEFAULT_LEAD_TIME_CHIPS])].slice(0, 3);
async function save() {
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
if (!canSave) return;
const synthetic: OnCallEntry = {
id: "",
@@ -112,6 +118,7 @@ export function AdminQuickAddSheet({
const payload: unknown = await response.json();
const parsed = onCallEntrySchema.safeParse((payload as { entry?: unknown } | null)?.entry);
if (!parsed.success) throw new Error("Save response was invalid.");
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
onSaved(parsed.data);
// A completed save, not a dismissed draft — Addendum A keeps a
// dismissed half-filled sheet in memory, but this transaction finished,
@@ -221,3 +228,11 @@ export function AdminQuickAddSheet({
);
}
+
+export function AdminQuickAddSheet(props: Parameters[0]) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/admin/admin-renewals-page.tsx b/src/components/admin/admin-renewals-page.tsx
index 44db33ad59..379b9f6390 100644
--- a/src/components/admin/admin-renewals-page.tsx
+++ b/src/components/admin/admin-renewals-page.tsx
@@ -1,5 +1,9 @@
"use client";
+import { OnCallEntrySessionBoundary } from "@/components/on-call/on-call-entry-session-boundary";
+
+import { peekOnCallEntrySessionEpoch } from "@/lib/on-call/entry-cache-keys";
+
import { CalendarPlus, Copy, Ellipsis, Plus, RotateCw } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -128,7 +132,8 @@ function keepViewFor(kind: "catalogue" | "personal", current: RenewalsView): Ren
return current === "group" ? "group" : "checklist";
}
-export function AdminRenewalsPage({ now: nowProp }: { now?: Date } = {}) {
+function AdminRenewalsPageSession({ now: nowProp }: { now?: Date } = {}) {
+ const sessionEpoch = peekOnCallEntrySessionEpoch();
const state = useOnCallEntries();
const now = useJuniorNow(nowProp);
// `cacheOnCallEntries` (called from `upsert` below) writes through to the
@@ -229,9 +234,10 @@ export function AdminRenewalsPage({ now: nowProp }: { now?: Date } = {}) {
}
function upsert(entry: OnCallEntry) {
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
// The latest cache, not this render's list: two saves in flight would otherwise undo each other.
const latest = readCachedOnCallEntries()?.entries ?? state.entries;
- cacheOnCallEntries([...latest.filter((existing) => existing.id !== entry.id), entry]);
+ cacheOnCallEntries([...latest.filter((existing) => existing.id !== entry.id), entry], sessionEpoch);
setEntryVersion((version) => version + 1);
setDetailSubject((current) => {
if (!current || !current.entry || current.entry.id !== entry.id) return current;
@@ -242,12 +248,17 @@ export function AdminRenewalsPage({ now: nowProp }: { now?: Date } = {}) {
}
function removeEntry(id: string) {
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
const latest = readCachedOnCallEntries()?.entries ?? state.entries;
- cacheOnCallEntries(latest.filter((existing) => existing.id !== id));
+ cacheOnCallEntries(
+ latest.filter((existing) => existing.id !== id),
+ sessionEpoch,
+ );
setEntryVersion((version) => version + 1);
}
async function setIssuerCheck(entry: OnCallEntry, checkedOn: string | null) {
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
const result = buildIssuerCheckStampBody(entry, checkedOn);
if (!result.ok) throw new Error("Use the date as YYYY-MM-DD.");
const saved = await parsedEntry(
@@ -257,6 +268,7 @@ export function AdminRenewalsPage({ now: nowProp }: { now?: Date } = {}) {
body: JSON.stringify(result.body),
}),
);
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
upsert(saved);
}
@@ -369,6 +381,7 @@ export function AdminRenewalsPage({ now: nowProp }: { now?: Date } = {}) {
}
async function setNotForThisJob(entry: OnCallEntry, flag: boolean) {
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
const saved = await parsedEntry(
await fetch(`/api/on-call/entries/${entry.id}`, {
method: "PATCH",
@@ -376,10 +389,12 @@ export function AdminRenewalsPage({ now: nowProp }: { now?: Date } = {}) {
body: JSON.stringify(buildNotForThisJobToggleBody(entry, flag)),
}),
);
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
upsert(saved);
setUndoBar({
message: flag ? `Not for this job · ${entry.title}` : `Moved back · ${entry.title}`,
undo: async () => {
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
const restored = await parsedEntry(
await fetch(`/api/on-call/entries/${entry.id}`, {
method: "PATCH",
@@ -394,6 +409,7 @@ export function AdminRenewalsPage({ now: nowProp }: { now?: Date } = {}) {
/** "Not for this job" on a catalogue item never recorded: a minimal row, and Undo deletes it. */
async function markItemNotForThisJob(item: CatalogueItem) {
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
const created = await parsedEntry(
await fetch("/api/on-call/entries", {
method: "POST",
@@ -401,10 +417,12 @@ export function AdminRenewalsPage({ now: nowProp }: { now?: Date } = {}) {
body: JSON.stringify(buildNotForThisJobCreateBody(item, crypto.randomUUID().slice(0, 6))),
}),
);
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
upsert(created);
setUndoBar({
message: `Not for this job · ${item.title}`,
undo: async () => {
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
const response = await fetch(`/api/on-call/entries/${created.id}`, { method: "DELETE" });
if (!response.ok) throw await parseApiErrorResponse(response);
removeEntry(created.id);
@@ -716,3 +734,11 @@ export function AdminRenewalsPage({ now: nowProp }: { now?: Date } = {}) {
);
}
+
+export function AdminRenewalsPage(props: Parameters[0] = {}) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/admin/admin-renewed-sheet.tsx b/src/components/admin/admin-renewed-sheet.tsx
index 120f63b609..a0bc904a91 100644
--- a/src/components/admin/admin-renewed-sheet.tsx
+++ b/src/components/admin/admin-renewed-sheet.tsx
@@ -1,5 +1,9 @@
"use client";
+import { OnCallEntrySessionBoundary } from "@/components/on-call/on-call-entry-session-boundary";
+
+import { peekOnCallEntrySessionEpoch } from "@/lib/on-call/entry-cache-keys";
+
import { useState } from "react";
import { focusRing } from "@/components/card-recipes";
@@ -45,7 +49,7 @@ const REASON_MESSAGE: Record, {
* catalogue item for the first time (POST), reusing the exact same body
* builder and error wording as the renew path.
*/
-export function AdminRenewedSheet({
+function AdminRenewedSheetSession({
open,
entry,
createItem,
@@ -68,6 +72,7 @@ export function AdminRenewedSheet({
/** Undo of a first-time date ("Add date"): the row this sheet created was deleted. */
onRemoved: (id: string) => void;
}) {
+ const sessionEpoch = peekOnCallEntrySessionEpoch();
const subjectTitle = entry?.title ?? createItem?.title ?? "";
const [date, setDate] = useState("");
const [note, setNote] = useState("");
@@ -94,6 +99,7 @@ export function AdminRenewedSheet({
const previousExpiresOn = entry ? complianceExpiresOn(entry) : undefined;
async function save() {
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
// Belt and braces: an example row is never written to the account.
if (entry && isOnCallExampleEntry(entry)) return;
const subject = entry ?? (createItem ? catalogueItemDraftEntry(createItem) : null);
@@ -114,9 +120,11 @@ export function AdminRenewedSheet({
if (!response.ok) throw await parseApiErrorResponse(response);
const payload: unknown = await response.json();
const parsed = onCallEntrySchema.safeParse((payload as { entry?: unknown } | null)?.entry);
- if (!parsed.success) throw new Error("Save response was invalid.");
+ if (!parsed.success || (entry && parsed.data.id !== entry.id)) throw new Error("Save response was invalid.");
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
setSaved(parsed.data);
setEarlier(result.earlier);
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
onSaved(parsed.data);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not save this.");
@@ -126,6 +134,7 @@ export function AdminRenewedSheet({
}
async function undo() {
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
if (!saved) return;
if (entry && isOnCallExampleEntry(entry)) return;
setBusy(true);
@@ -134,6 +143,7 @@ export function AdminRenewedSheet({
// A first-time date created this row, so taking it back deletes it.
const removed = await fetch(`/api/on-call/entries/${saved.id}`, { method: "DELETE" });
if (!removed.ok) throw await parseApiErrorResponse(removed);
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
onRemoved(saved.id);
setSaved(null);
return;
@@ -146,7 +156,8 @@ export function AdminRenewedSheet({
if (!response.ok) throw await parseApiErrorResponse(response);
const payload: unknown = await response.json();
const parsed = onCallEntrySchema.safeParse((payload as { entry?: unknown } | null)?.entry);
- if (!parsed.success) throw new Error("Undo response was invalid.");
+ if (!parsed.success || parsed.data.id !== entry.id) throw new Error("Undo response was invalid.");
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
onSaved(parsed.data);
setSaved(null);
} catch (caught) {
@@ -245,3 +256,11 @@ export function AdminRenewedSheet({
);
}
+
+export function AdminRenewedSheet(props: Parameters[0]) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/admin/admin-setup-sheet.tsx b/src/components/admin/admin-setup-sheet.tsx
index 1a1352f01f..699ebaa1c7 100644
--- a/src/components/admin/admin-setup-sheet.tsx
+++ b/src/components/admin/admin-setup-sheet.tsx
@@ -1,5 +1,7 @@
"use client";
+import { OnCallEntrySessionBoundary } from "@/components/on-call/on-call-entry-session-boundary";
+
import { useState } from "react";
import { InlineNotice } from "@/components/primitive-recipes/feedback";
@@ -15,6 +17,7 @@ import {
} from "@/lib/admin/setup";
import { parseApiErrorResponse } from "@/lib/api-client-error";
import { onCallEntrySchema, type OnCallEntry } from "@/lib/on-call/entry-model";
+import { peekOnCallEntrySessionEpoch } from "@/lib/on-call/entry-cache-keys";
const LABELS: Record = {
registration: "Registration expiry",
@@ -35,7 +38,8 @@ export interface AdminSetupSheetProps {
* is written to the device — every save goes straight to the existing entry
* API, through the same contract Renewals uses.
*/
-export function AdminSetupSheet({ open, onClose, existingEntries, onCreated }: AdminSetupSheetProps) {
+function AdminSetupSheetSession({ open, onClose, existingEntries, onCreated }: AdminSetupSheetProps) {
+ const sessionEpoch = peekOnCallEntrySessionEpoch();
const [dates, setDates] = useState>({ registration: "", indemnity: "" });
// Rows already created in this sheet, so a retry after a partial failure
// never re-sends the one that already saved.
@@ -53,6 +57,10 @@ export function AdminSetupSheet({ open, onClose, existingEntries, onCreated }: A
setError(null);
const pending = setupRequirementsToCreate(existingEntries, dates).filter((kind) => !done.includes(kind));
for (const kind of pending) {
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) {
+ setBusy(false);
+ return;
+ }
try {
const response = await fetch("/api/on-call/entries", {
method: "POST",
@@ -62,6 +70,10 @@ export function AdminSetupSheet({ open, onClose, existingEntries, onCreated }: A
if (!response.ok) throw await parseApiErrorResponse(response);
const parsed = onCallEntrySchema.safeParse(((await response.json()) as { entry?: unknown }).entry);
if (!parsed.success) throw new Error("The saved row came back unreadable.");
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) {
+ setBusy(false);
+ return;
+ }
onCreated(parsed.data);
setDone((previous) => [...previous, kind]);
} catch (cause) {
@@ -120,3 +132,11 @@ export function AdminSetupSheet({ open, onClose, existingEntries, onCreated }: A
);
}
+
+export function AdminSetupSheet(props: Parameters[0]) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/admin/admin-today-page.tsx b/src/components/admin/admin-today-page.tsx
index 24818c0223..84a501bcb3 100644
--- a/src/components/admin/admin-today-page.tsx
+++ b/src/components/admin/admin-today-page.tsx
@@ -1,5 +1,9 @@
"use client";
+import { OnCallEntrySessionBoundary } from "@/components/on-call/on-call-entry-session-boundary";
+
+import { peekOnCallEntrySessionEpoch } from "@/lib/on-call/entry-cache-keys";
+
import { Phone, Plus } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
@@ -96,7 +100,8 @@ function HelpRow() {
);
}
-export function AdminTodayPage({ now: nowProp }: { now?: Date } = {}) {
+function AdminTodayPageSession({ now: nowProp }: { now?: Date } = {}) {
+ const sessionEpoch = peekOnCallEntrySessionEpoch();
const { zone } = useWorkTimeZone();
const state = useOnCallEntries();
const { isAuthenticated } = useAccountData();
@@ -136,8 +141,9 @@ export function AdminTodayPage({ now: nowProp }: { now?: Date } = {}) {
const helpItems = useMemo(() => buildAdminHelpItems({ own, shared, statewide: [] }), [own, shared]);
function upsert(entry: OnCallEntry) {
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
const latest = readCachedOnCallEntries()?.entries ?? state.entries;
- cacheOnCallEntries([...latest.filter((existing) => existing.id !== entry.id), entry]);
+ cacheOnCallEntries([...latest.filter((existing) => existing.id !== entry.id), entry], sessionEpoch);
}
const failedCopy =
@@ -212,3 +218,11 @@ export function AdminTodayPage({ now: nowProp }: { now?: Date } = {}) {
);
}
+
+export function AdminTodayPage(props: Parameters[0] = {}) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/admin/contract/contract-end-page.tsx b/src/components/admin/contract/contract-end-page.tsx
index 895125b143..a6310b0567 100644
--- a/src/components/admin/contract/contract-end-page.tsx
+++ b/src/components/admin/contract/contract-end-page.tsx
@@ -1,5 +1,9 @@
"use client";
+import { OnCallEntrySessionBoundary } from "@/components/on-call/on-call-entry-session-boundary";
+
+import { peekOnCallEntrySessionEpoch } from "@/lib/on-call/entry-cache-keys";
+
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Bell, CalendarPlus, ChevronRight, FileClock, Layers, Mail, PenLine, Send, Trash2 } from "lucide-react";
@@ -197,7 +201,8 @@ function ReadOnlyLine({
* planned before the end. One private compliance row through the existing
* entries API, so Admin Today and Renewals see it too. No device storage.
*/
-export function ContractEndPage({ now: nowProp }: { now?: Date } = {}) {
+function ContractEndPageSession({ now: nowProp }: { now?: Date } = {}) {
+ const sessionEpoch = peekOnCallEntrySessionEpoch();
const { isAuthenticated } = useAccountData();
const state = useOnCallEntries();
const now = useJuniorNow(nowProp);
@@ -238,25 +243,32 @@ export function ContractEndPage({ now: nowProp }: { now?: Date } = {}) {
const leave = useJuniorRosterLeave(loadState === "ready" && Boolean(endsOn));
function upsert(saved: OnCallEntry) {
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
// The latest cache, not this render's list: two saves in flight would otherwise undo each other.
const entries = readCachedOnCallEntries()?.entries ?? state.entries;
cacheOnCallEntries(
entries.some((existing) => existing.id === saved.id)
? entries.map((existing) => (existing.id === saved.id ? saved : existing))
: [...entries, saved],
+ sessionEpoch,
);
}
function remove(id: string) {
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
const entries = readCachedOnCallEntries()?.entries ?? state.entries;
- cacheOnCallEntries(entries.filter((existing) => existing.id !== id));
+ cacheOnCallEntries(
+ entries.filter((existing) => existing.id !== id),
+ sessionEpoch,
+ );
}
function offerUndo(label: string, run: () => Promise) {
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
undoId.current += 1;
setUndo({ id: undoId.current, label, run });
}
async function restore(original: OnCallEntry) {
- const saved = await patchEntry(original.id, buildRestoreEntryBody(original));
+ const saved = await patchEntry(original.id, buildRestoreEntryBody(original), sessionEpoch);
upsert(saved);
}
@@ -264,14 +276,14 @@ export function ContractEndPage({ now: nowProp }: { now?: Date } = {}) {
try {
if (entry) {
const original = entry;
- const saved = await patchEntry(entry.id, buildContractEditBody(entry, input));
+ const saved = await patchEntry(entry.id, buildContractEditBody(entry, input), sessionEpoch);
upsert(saved);
offerUndo("Contract dates saved", () => restore(original));
} else {
- const saved = await createEntry(buildContractCreateBody(input, slugSuffix()));
+ const saved = await createEntry(buildContractCreateBody(input, slugSuffix()), sessionEpoch);
upsert(saved);
offerUndo("Contract end saved", async () => {
- await deleteEntry(saved.id);
+ await deleteEntry(saved.id, sessionEpoch);
remove(saved.id);
});
}
@@ -288,7 +300,7 @@ export function ContractEndPage({ now: nowProp }: { now?: Date } = {}) {
if (!result.ok) return "Check the new end date.";
try {
const original = entry;
- const saved = await patchEntry(entry.id, result.body);
+ const saved = await patchEntry(entry.id, result.body, sessionEpoch);
upsert(saved);
setSheet(null);
offerUndo("New end date saved. Reminders moved", () => restore(original));
@@ -302,7 +314,7 @@ export function ContractEndPage({ now: nowProp }: { now?: Date } = {}) {
if (!entry || ids.length === 0) return null;
try {
const original = entry;
- const saved = await patchEntry(entry.id, buildContractAskedBody(entry, ids, asked));
+ const saved = await patchEntry(entry.id, buildContractAskedBody(entry, ids, asked), sessionEpoch);
upsert(saved);
const label = asked
? ids.length === 1
@@ -325,7 +337,7 @@ export function ContractEndPage({ now: nowProp }: { now?: Date } = {}) {
setFailure(null);
try {
const original = entry;
- const saved = await patchEntry(entry.id, body);
+ const saved = await patchEntry(entry.id, body, sessionEpoch);
upsert(saved);
const name = key === "threeMonths" ? "3 month" : "6 week";
offerUndo(`${name} reminder ${next[key] ? "on" : "off"}`, () => restore(original));
@@ -342,10 +354,10 @@ export function ContractEndPage({ now: nowProp }: { now?: Date } = {}) {
setFailure(null);
try {
const original = entry;
- await deleteEntry(entry.id);
+ await deleteEntry(entry.id, sessionEpoch);
remove(entry.id);
offerUndo("Contract end removed", async () => {
- const recreated = await createEntry({ ...buildRestoreEntryBody(original) });
+ const recreated = await createEntry({ ...buildRestoreEntryBody(original) }, sessionEpoch);
upsert(recreated);
});
} catch (error) {
@@ -791,3 +803,11 @@ export function ContractEndPage({ now: nowProp }: { now?: Date } = {}) {
>
);
}
+
+export function ContractEndPage(props: Parameters[0] = {}) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/admin/junior/junior-shared.tsx b/src/components/admin/junior/junior-shared.tsx
index 685595a737..dc7c6c3442 100644
--- a/src/components/admin/junior/junior-shared.tsx
+++ b/src/components/admin/junior/junior-shared.tsx
@@ -1,4 +1,5 @@
"use client";
+import { peekOnCallEntrySessionEpoch } from "@/lib/on-call/entry-cache-keys";
import { ChevronLeft, ChevronRight, Lock, TriangleAlert, type LucideIcon } from "lucide-react";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
@@ -27,22 +28,31 @@ export const JUNIOR_UNDO_MS = 10_000;
/* ------------------------------------------------------------ writes */
-async function entryFrom(response: Response): Promise {
+async function entryFrom(response: Response, expectedId?: string): Promise {
if (!response.ok) throw await parseApiErrorResponse(response);
const json: unknown = await response.json();
const parsed = onCallEntrySchema.safeParse((json as { entry?: unknown } | null)?.entry);
- if (!parsed.success) throw new Error("Save response was invalid.");
+ if (!parsed.success || (expectedId !== undefined && parsed.data.id !== expectedId))
+ throw new Error("Save response was invalid.");
return parsed.data;
}
-export async function createEntry(body: unknown): Promise {
- return entryFrom(
+function requireEntrySession(expectedEpoch: number): void {
+ if (expectedEpoch !== peekOnCallEntrySessionEpoch())
+ throw new Error("Your account changed. Reopen this page to continue.");
+}
+
+export async function createEntry(body: unknown, expectedEpoch = peekOnCallEntrySessionEpoch()): Promise {
+ requireEntrySession(expectedEpoch);
+ const saved = await entryFrom(
await fetch("/api/on-call/entries", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
+ requireEntrySession(expectedEpoch);
+ return saved;
}
/** Belt and braces: an example row is never written to the account. */
@@ -50,21 +60,31 @@ function refuseExampleWrite(id: string): void {
if (isExampleRecord(id)) throw new Error("Example records can't be changed. Turn example data off to edit your own.");
}
-export async function patchEntry(id: string, body: unknown): Promise {
+export async function patchEntry(
+ id: string,
+ body: unknown,
+ expectedEpoch = peekOnCallEntrySessionEpoch(),
+): Promise {
+ requireEntrySession(expectedEpoch);
refuseExampleWrite(id);
- return entryFrom(
+ const saved = await entryFrom(
await fetch(`/api/on-call/entries/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
+ id,
);
+ requireEntrySession(expectedEpoch);
+ return saved;
}
-export async function deleteEntry(id: string): Promise {
+export async function deleteEntry(id: string, expectedEpoch = peekOnCallEntrySessionEpoch()): Promise {
+ requireEntrySession(expectedEpoch);
refuseExampleWrite(id);
const response = await fetch(`/api/on-call/entries/${id}`, { method: "DELETE" });
if (!response.ok) throw await parseApiErrorResponse(response);
+ requireEntrySession(expectedEpoch);
}
export function errorWords(error: unknown, fallback: string): string {
diff --git a/src/components/admin/renewals/record-dates-sheet.tsx b/src/components/admin/renewals/record-dates-sheet.tsx
index fc4a39b82e..468209633b 100644
--- a/src/components/admin/renewals/record-dates-sheet.tsx
+++ b/src/components/admin/renewals/record-dates-sheet.tsx
@@ -1,5 +1,9 @@
"use client";
+import { OnCallEntrySessionBoundary } from "@/components/on-call/on-call-entry-session-boundary";
+
+import { peekOnCallEntrySessionEpoch } from "@/lib/on-call/entry-cache-keys";
+
import { useEffect, useRef, useState } from "react";
import { InlineNotice } from "@/components/primitive-recipes/feedback";
@@ -64,7 +68,7 @@ function recordDatesSummary({ recorded, skipped, notForThisJob }: Tally): string
* `items` is the queue as it stood when the sheet opened, so recording an
* item never reshuffles the steps under the reader. Mounted per opening.
*/
-export function RecordDatesSheet({
+function RecordDatesSheetSession({
items,
readOnly,
onClose,
@@ -81,6 +85,7 @@ export function RecordDatesSheet({
readonly onRetryLoad?: () => void;
readonly testId?: string;
}) {
+ const sessionEpoch = peekOnCallEntrySessionEpoch();
const [index, setIndex] = useState(0);
const [date, setDate] = useState("");
const [busy, setBusy] = useState(false);
@@ -111,6 +116,7 @@ export function RecordDatesSheet({
}
async function run(action: () => Promise, failureMessage: string) {
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
setBusy(true);
setFailure(null);
try {
@@ -133,6 +139,7 @@ export function RecordDatesSheet({
const body = result.body;
void run(async () => {
const saved = await postEntry(body);
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
onSaved(saved);
advance("recorded");
}, `Couldn't save ${item.title}.`);
@@ -148,6 +155,7 @@ export function RecordDatesSheet({
const body = buildNotForThisJobCreateBody(item, suffix);
void run(async () => {
const saved = await postEntry(body);
+ if (sessionEpoch !== peekOnCallEntrySessionEpoch()) return;
onSaved(saved);
advance("notForThisJob");
}, `Couldn't save ${item.title}.`);
@@ -272,3 +280,11 @@ export function RecordDatesSheet({
);
}
+
+export function RecordDatesSheet(props: Parameters[0]) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/admin/starter/starter-pack-page.tsx b/src/components/admin/starter/starter-pack-page.tsx
index 014b7a502f..b97c90225b 100644
--- a/src/components/admin/starter/starter-pack-page.tsx
+++ b/src/components/admin/starter/starter-pack-page.tsx
@@ -1,5 +1,9 @@
"use client";
+import { OnCallEntrySessionBoundary } from "@/components/on-call/on-call-entry-session-boundary";
+
+import { peekOnCallEntrySessionEpoch } from "@/lib/on-call/entry-cache-keys";
+
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { CalendarPlus, ChevronRight, Copy, ExternalLink, Search, TriangleAlert, WifiOff, X } from "lucide-react";
@@ -55,7 +59,7 @@ import {
type StarterDateKind,
} from "@/lib/admin/starter-pack";
import { perthCalendarDate } from "@/lib/cme/cpd-year";
-import { cacheOnCallEntries, useOnCallEntries } from "@/lib/on-call/entry-store";
+import { updateCachedOnCallEntries, useOnCallEntries } from "@/lib/on-call/entry-store";
import { checkPatientDetail } from "@/lib/work-text/patient-detail-check";
type Undo = { id: number; label: string; run: () => Promise };
@@ -280,7 +284,8 @@ function SuggestWord({ initial }: { initial: string }) {
* word from home, the doctor's own visa and registration dates beside their
* contract end, and who to ask. Never visa or registration advice.
*/
-export function StarterPackPage({ now: nowProp }: { now?: Date } = {}) {
+function StarterPackPageSession({ now: nowProp }: { now?: Date } = {}) {
+ const sessionEpoch = peekOnCallEntrySessionEpoch();
const { isAuthenticated } = useAccountData();
const searchParams = useSearchParams();
const wordParam = searchParams?.get("word") ?? "";
@@ -327,8 +332,12 @@ export function StarterPackPage({ now: nowProp }: { now?: Date } = {}) {
const body = buildStarterDateBody(input, slugSuffix());
if (!body) return "Check the date.";
try {
- const saved = await createEntry(body);
- cacheOnCallEntries([...state.entries, saved]);
+ const saved = await createEntry(body, sessionEpoch);
+ updateCachedOnCallEntries(
+ state.entries,
+ (current) => [...current.filter((entry) => entry.id !== saved.id), saved],
+ sessionEpoch,
+ );
setSheetOpen(false);
const title = input.kind === "other" ? input.name.trim() : starterKindInfo(input.kind).label;
undoId.current += 1;
@@ -336,8 +345,12 @@ export function StarterPackPage({ now: nowProp }: { now?: Date } = {}) {
id: undoId.current,
label: starterSavedLine(title, input.date, input.leadTimeDays),
run: async () => {
- await deleteEntry(saved.id);
- cacheOnCallEntries(entriesRef.current.filter((entry) => entry.id !== saved.id));
+ await deleteEntry(saved.id, sessionEpoch);
+ updateCachedOnCallEntries(
+ entriesRef.current,
+ (current) => current.filter((entry) => entry.id !== saved.id),
+ sessionEpoch,
+ );
},
});
return null;
@@ -674,3 +687,11 @@ export function StarterPackPage({ now: nowProp }: { now?: Date } = {}) {
>
);
}
+
+export function StarterPackPage(props: Parameters[0] = {}) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/clinical-dashboard/clinical-ask-answer-surface.tsx b/src/components/clinical-dashboard/clinical-ask-answer-surface.tsx
index c70475f2db..b0214f68cf 100644
--- a/src/components/clinical-dashboard/clinical-ask-answer-surface.tsx
+++ b/src/components/clinical-dashboard/clinical-ask-answer-surface.tsx
@@ -7,7 +7,7 @@ import { announce } from "@/components/ui/live-announcer";
import { copyTextToClipboard } from "@/lib/copy-to-clipboard";
import type { AnswerFeedbackType } from "@/lib/answer-feedback";
import type { ClinicalAskFeedbackMetadata, ClinicalAskResponse } from "@/lib/clinical-ask/contracts";
-import { clinicalAskModeProfile } from "@/lib/clinical-ask/mode-profiles";
+import { clinicalAskModeProfile, clinicalAskSectionTitle } from "@/lib/clinical-ask/mode-profiles";
const verificationReminder = "Clinician Confirmation is required for clinically material suggestions.";
const clinicalAskFeedbackChoices: ReadonlyArray<{ value: AnswerFeedbackType; label: string }> = [
@@ -34,7 +34,11 @@ export function clinicalAskExportText(
question ? `Question: ${question}` : null,
"",
response.lead.text,
- ...response.sections.flatMap((section) => ["", section.title, ...section.claims.map((claim) => claim.text)]),
+ ...response.sections.flatMap((section) => [
+ "",
+ clinicalAskSectionTitle(response.mode, section.id),
+ ...section.claims.map((claim) => claim.text),
+ ]),
"",
"Caveats and missing information",
...(response.missingInformation.length ? response.missingInformation : ["None stated."]),
@@ -210,7 +214,7 @@ export function ClinicalAskAnswerSurface({
{response.sections.map((section) => (
- {section.title}
+ {clinicalAskSectionTitle(response.mode, section.id)}
{section.claims.map((claim) => (
{claim.text}
))}
diff --git a/src/components/clinical-dashboard/use-clinical-ask-runner.ts b/src/components/clinical-dashboard/use-clinical-ask-runner.ts
index 13d274a84f..b341bb6331 100644
--- a/src/components/clinical-dashboard/use-clinical-ask-runner.ts
+++ b/src/components/clinical-dashboard/use-clinical-ask-runner.ts
@@ -55,8 +55,9 @@ export function useClinicalAskRunner({
clinicalAskSession.receiveEvent(event);
};
void import("@/lib/clinical-ask/client-stream")
- .then(({ streamClinicalAsk }) =>
- streamClinicalAsk(
+ .then(({ streamClinicalAsk }) => {
+ if (controller.signal.aborted) return;
+ return streamClinicalAsk(
{
mode: clinicalAskMode,
question: submittedQuery,
@@ -68,8 +69,8 @@ export function useClinicalAskRunner({
},
controller.signal,
receiveCurrentEvent,
- ),
- )
+ );
+ })
.then((payload) => {
// HTTP, parse, and network failures can return a failed payload before
// delivering a terminal event. Close the pending state exactly once.
diff --git a/src/components/cme/cme-entry-route-client.tsx b/src/components/cme/cme-entry-route-client.tsx
index c30fda02cb..7b4a4968e2 100644
--- a/src/components/cme/cme-entry-route-client.tsx
+++ b/src/components/cme/cme-entry-route-client.tsx
@@ -56,11 +56,32 @@ function updatePayload(entry: CmeEntry, draft: CmeEntryDraft, transcribed = entr
sourceUrl: draft.sourceUrl ?? null,
buckets: draft.buckets,
formalPeerReviewHours: draft.formalPeerReviewHours ?? 0,
+ expectedVersion: entry.version,
transcribed,
};
}
-export function CmeEntryRouteClient({ entry, set, edit, demoMode, goals = [] }: CmeEntryRouteClientProps) {
+export function CmeEntryRouteClient(props: CmeEntryRouteClientProps) {
+ return ;
+}
+
+function CmeEntryRouteSession({
+ entry: currentEntry,
+ set: currentSet,
+ edit,
+ demoMode,
+ goals = [],
+}: CmeEntryRouteClientProps) {
+ // The form owns its initial field state. Keep its original record and CAS version in
+ // that same identity/edit session so an RSC refresh cannot bless an older draft with
+ // a newer version. Rekey only for another activity or entering/leaving edit mode.
+ const [editorSeed] = useState(() => ({ entry: currentEntry, set: currentSet }));
+ const entry = edit ? editorSeed.entry : currentEntry;
+ const set = edit ? editorSeed.set : currentSet;
+ const editorChanged =
+ edit &&
+ (JSON.stringify(currentEntry?.version) !== JSON.stringify(entry?.version) ||
+ Boolean(currentSet.closedAt) !== Boolean(set.closedAt));
const router = useRouter();
const [archivePending, setArchivePending] = useState(false);
const [archiveError, setArchiveError] = useState(null);
@@ -117,6 +138,13 @@ export function CmeEntryRouteClient({ entry, set, edit, demoMode, goals = [] }:
? `${set.year} is closed. Your change is recorded as a dated amendment with your reason, beside the original. The closing snapshot does not change.`
: "Review the whole record before saving. Cancel, link navigation, closing and reloading all guard unsaved changes."}
+ {editorChanged ? (
+
+
+ This activity changed while you were editing. Your draft is still here. Reload the activity before saving.
+
+
+ ) : null}
{amendable ? (
= queue.length;
const current = finished ? undefined : lookup(queue[index]!);
+ // Other years must be opened to establish their closed state before changing bookkeeping.
+ const currentYearKnown = current?.date.startsWith(`${set.year}-`) ?? false;
+ const trackingReadOnly = Boolean(set.closedAt) || !currentYearKnown;
const text = current && !current.archivedAt ? formatEntryForCpdHome(current, entryYearSet(set, current)) : "";
const fields = text ? cpdHomeFields(text) : [];
const stillNotCopied = queue.filter((id) => {
@@ -98,7 +101,7 @@ export function CmeLogCopySheet({
}
async function markAndNext() {
- if (!current || busy || demoMode) return;
+ if (!current || busy || demoMode || trackingReadOnly) return;
setBusy(true);
setError(null);
try {
@@ -152,24 +155,35 @@ export function CmeLogCopySheet({
type="button"
className="work-button min-h-tap flex-1"
data-variant="secondary"
- onClick={() => moveTo(index + 1)}
+ onClick={() => (trackingReadOnly ? onClose() : moveTo(index + 1))}
disabled={busy}
data-testid="cme-log-copy-skip"
>
- Skip
-
-
+ {trackingReadOnly ? (
+
+ ) : (
+
+ )}
);
@@ -196,7 +210,15 @@ export function CmeLogCopySheet({
) : null}
- {lastMarked && !demoMode ? (
+ {!finished && trackingReadOnly ? (
+
+ {currentYearKnown && set.closedAt
+ ? "This year is closed. Copying is available; its recorded copied status stays unchanged."
+ : "Open this activity's year to change its copied status. Copying is still available here."}
+
+ ) : null}
+
+ {lastMarked && !demoMode && !set.closedAt && lastMarked.date.startsWith(`${set.year}-`) ? (
diff --git a/src/components/cme/cme-log-page.tsx b/src/components/cme/cme-log-page.tsx
index 78d0db6e1c..79eda9700d 100644
--- a/src/components/cme/cme-log-page.tsx
+++ b/src/components/cme/cme-log-page.tsx
@@ -245,6 +245,9 @@ export function CmeLogPage({
const categoryLabel = CATEGORY_OPTIONS.find((option) => option.value === categoryFilter)?.label ?? "All categories";
async function patchCopied(id: string, transcribed: boolean) {
+ if (set.closedAt || !entriesById.get(id)?.date.startsWith(`${set.year}-`)) {
+ throw new Error("Open an unclosed year to change its copied status.");
+ }
const response = await fetch(`/api/cme/entries/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
@@ -475,7 +478,11 @@ export function CmeLogPage({
{` not marked copied`}
{` to ${cpdHome.name}`}
-
{`Copy one, paste it into ${cpdHome.name}`}
+
+ {set.closedAt
+ ? `Copy into ${cpdHome.name}. This closed year's copied status stays unchanged.`
+ : `Copy one, paste it into ${cpdHome.name}`}
+
);
}
+
+export function TeachingThisWeek(props: Parameters[0]) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/teaching/teaching-week.tsx b/src/components/teaching/teaching-week.tsx
index 18bff1a78f..ce2f5bc1a0 100644
--- a/src/components/teaching/teaching-week.tsx
+++ b/src/components/teaching/teaching-week.tsx
@@ -1,5 +1,7 @@
"use client";
+import { OnCallEntrySessionBoundary } from "@/components/on-call/on-call-entry-session-boundary";
+
import { ChevronLeft, ChevronRight } from "lucide-react";
import dynamic from "next/dynamic";
import Link from "next/link";
@@ -65,7 +67,7 @@ export type WeekSidePanel = (panel: {
close: () => void;
}) => ReactNode;
-export function TeachingWeekScreen({
+function TeachingWeekScreenSession({
demoMode: serverDemoMode,
sidePanel,
}: {
@@ -358,3 +360,11 @@ function WeekBody({
>
);
}
+
+export function TeachingWeekScreen(props: Parameters[0]) {
+ return (
+
+
+
+ );
+}
diff --git a/src/lib/calendar/feed-repository.ts b/src/lib/calendar/feed-repository.ts
index 8006cb49a4..0240047c21 100644
--- a/src/lib/calendar/feed-repository.ts
+++ b/src/lib/calendar/feed-repository.ts
@@ -1,7 +1,7 @@
import "server-only";
import { calendarFeedPath, generateCalendarFeedToken, hashCalendarFeedToken } from "@/lib/calendar/feed-token";
-import { cmeDeadlineEvents, cmeRoutineEvents } from "@/lib/cme/calendar-events";
+import { cmeDeadlineEvents, cmeRoutineEvents, cmeReportingWindowClose } from "@/lib/cme/calendar-events";
import { cpdYearOf, perthCalendarDate } from "@/lib/cme/cpd-year";
import { fetchOwnerCmeRoutines, fetchOwnerCmeYear } from "@/lib/cme/repository";
import type { CalendarEvent } from "@/lib/calendar/calendar-event";
@@ -184,18 +184,30 @@ async function fetchOwnerBookingEvents(supabase: AdminClient, ownerId: string, n
export async function calendarFeedEvents(supabase: AdminClient, ownerId: string, now: Date): Promise {
const year = cpdYearOf(now);
- const [thisYear, nextYear, routines, teaching, teachingSessions, reminders, rosterSettings, rotations, bookings] =
- await Promise.all([
- fetchOwnerCmeYear(supabase, ownerId, year),
- fetchOwnerCmeYear(supabase, ownerId, year + 1),
- fetchOwnerCmeRoutines(supabase, ownerId),
- fetchVisibleOnCallEntries(supabase, ownerId, { section: "education" }),
- fetchTeachingFeedSessions(supabase, ownerId, now),
- fetchOwnerReminderSettings(supabase, ownerId),
- fetchOwnerRosterSettingsForFeed(supabase, ownerId),
- fetchRotationFeedEvents(supabase, ownerId, now),
- fetchOwnerBookingEvents(supabase, ownerId, now),
- ]);
+ const today = perthCalendarDate(now);
+ const [
+ previousYear,
+ thisYear,
+ nextYear,
+ routines,
+ teaching,
+ teachingSessions,
+ reminders,
+ rosterSettings,
+ rotations,
+ bookings,
+ ] = await Promise.all([
+ today <= cmeReportingWindowClose(year - 1) ? fetchOwnerCmeYear(supabase, ownerId, year - 1) : null,
+ fetchOwnerCmeYear(supabase, ownerId, year),
+ fetchOwnerCmeYear(supabase, ownerId, year + 1),
+ fetchOwnerCmeRoutines(supabase, ownerId),
+ fetchVisibleOnCallEntries(supabase, ownerId, { section: "education" }),
+ fetchTeachingFeedSessions(supabase, ownerId, now),
+ fetchOwnerReminderSettings(supabase, ownerId),
+ fetchOwnerRosterSettingsForFeed(supabase, ownerId),
+ fetchRotationFeedEvents(supabase, ownerId, now),
+ fetchOwnerBookingEvents(supabase, ownerId, now),
+ ]);
let rosterShifts: CalendarEvent[] = [];
if (rosterSettings.calendarShifts) {
const own = await fetchOwnerShifts(supabase, ownerId, now);
@@ -204,6 +216,8 @@ export async function calendarFeedEvents(supabase: AdminClient, ownerId: string,
rosterShifts = rosterShiftEvents(shifts.filter((shift) => Date.parse(shift.startsAt) <= windowEndMillis));
}
const events = [
+ // Only the prior year's still-open reporting date belongs here, not its elapsed December dates.
+ ...(previousYear ? cmeDeadlineEvents(previousYear).filter((event) => event.date >= today) : []),
...(thisYear ? cmeDeadlineEvents(thisYear) : []),
...(nextYear ? cmeDeadlineEvents(nextYear) : []),
...cmeRoutineEvents(routines),
diff --git a/src/lib/clinical-ask-stream-contract.ts b/src/lib/clinical-ask-stream-contract.ts
index 9d8bec38b8..f536af5e3d 100644
--- a/src/lib/clinical-ask-stream-contract.ts
+++ b/src/lib/clinical-ask-stream-contract.ts
@@ -1,5 +1,6 @@
import { z } from "zod";
import { clinicalAskModeIds, type ClinicalAskStreamEvent } from "@/lib/clinical-ask/contracts";
+import { clinicalAskSectionTitle } from "@/lib/clinical-ask/mode-profiles";
const contextValue = z.union([z.string().max(500), z.array(z.string().max(500)).max(20)]);
const contextSchema = z.record(z.string(), contextValue);
@@ -104,7 +105,18 @@ const responseSchema = z
});
}
}
- });
+ })
+ .transform((response) =>
+ response.state === "answered"
+ ? {
+ ...response,
+ sections: response.sections.map((section) => ({
+ ...section,
+ title: clinicalAskSectionTitle(response.mode, section.id),
+ })),
+ }
+ : response,
+ );
const progressSchema = z
.object({
type: z.literal("progress"),
diff --git a/src/lib/clinical-ask/client-stream.ts b/src/lib/clinical-ask/client-stream.ts
index 386aaf32eb..4551b0d45f 100644
--- a/src/lib/clinical-ask/client-stream.ts
+++ b/src/lib/clinical-ask/client-stream.ts
@@ -75,6 +75,7 @@ export async function streamClinicalAsk(
signal: AbortSignal,
onEvent: (event: ClinicalAskStreamEvent) => void,
): Promise {
+ if (signal.aborted) return failedPayload(request, "aborted");
const controller = new AbortController();
const onAbort = () => controller.abort(signal.reason);
signal.addEventListener("abort", onAbort, { once: true });
diff --git a/src/lib/clinical-ask/mode-profiles.ts b/src/lib/clinical-ask/mode-profiles.ts
index 7d92a3744f..c360930c9f 100644
--- a/src/lib/clinical-ask/mode-profiles.ts
+++ b/src/lib/clinical-ask/mode-profiles.ts
@@ -133,3 +133,10 @@ export const clinicalAskModeProfiles = {
export function clinicalAskModeProfile(mode: ClinicalAskModeId): ClinicalAskModeProfile {
return clinicalAskModeProfiles[mode];
}
+
+/** Public headings come from the trusted section vocabulary, never model prose. */
+export function clinicalAskSectionTitle(mode: ClinicalAskModeId, sectionId: string): string {
+ if (!clinicalAskModeProfile(mode).sectionOrder.includes(sectionId)) return "Evidence";
+ const label = sectionId.replaceAll("_", " ");
+ return label.charAt(0).toUpperCase() + label.slice(1);
+}
diff --git a/src/lib/clinical-ask/orchestrator.ts b/src/lib/clinical-ask/orchestrator.ts
index 0ab9d1e9d0..7b9a3419c4 100644
--- a/src/lib/clinical-ask/orchestrator.ts
+++ b/src/lib/clinical-ask/orchestrator.ts
@@ -86,7 +86,7 @@ function abortReason(signal: AbortSignal) {
return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted.", "AbortError");
}
-async function withAbort(operation: Promise, signal: AbortSignal): Promise {
+async function withAbort(operation: () => Promise, signal: AbortSignal): Promise {
if (signal.aborted) throw abortReason(signal);
let rejectAbort: ((reason?: unknown) => void) | undefined;
const onAbort = () => rejectAbort?.(abortReason(signal));
@@ -95,7 +95,11 @@ async function withAbort(operation: Promise, signal: AbortSignal): Promise
signal.addEventListener("abort", onAbort, { once: true });
});
try {
- return await Promise.race([operation, aborted]);
+ const pending = Promise.resolve().then(() => {
+ if (signal.aborted) throw abortReason(signal);
+ return operation();
+ });
+ return await Promise.race([pending, aborted]);
} finally {
signal.removeEventListener("abort", onAbort);
}
@@ -125,11 +129,11 @@ export async function runClinicalAsk(
let retryAvailable = true;
const retryOnce = async (operation: () => Promise) => {
try {
- return await withAbort(operation(), operationSignal);
+ return await withAbort(operation, operationSignal);
} catch (error) {
if (!retryAvailable || operationSignal.aborted) throw error;
retryAvailable = false;
- return await withAbort(operation(), operationSignal);
+ return await withAbort(operation, operationSignal);
}
};
@@ -167,12 +171,12 @@ export async function runClinicalAsk(
emit("catalogue");
evidence.push(
- ...(await withAbort(dependencies.retrieveCatalogue(projectedRequest, operationSignal), operationSignal)),
+ ...(await withAbort(() => dependencies.retrieveCatalogue(projectedRequest, operationSignal), operationSignal)),
);
emit("indexed");
evidence.push(
...(await withAbort(
- dependencies.retrieveIndexed(projectedRequest, accessScope, operationSignal),
+ () => dependencies.retrieveIndexed(projectedRequest, accessScope, operationSignal),
operationSignal,
)),
);
@@ -189,7 +193,7 @@ export async function runClinicalAsk(
emit("external");
try {
const external = await withAbort(
- dependencies.retrieveExternal(projectedRequest, profile.allowedAuthorityIds, operationSignal),
+ () => dependencies.retrieveExternal(projectedRequest, profile.allowedAuthorityIds, operationSignal),
operationSignal,
);
evidence.push(...external);
@@ -222,7 +226,10 @@ export async function runClinicalAsk(
if (governed.state === "evidence_gap" && retryAvailable) {
retryAvailable = false;
try {
- draft = await withAbort(dependencies.synthesize(projectedRequest, evidence, operationSignal), operationSignal);
+ draft = await withAbort(
+ () => dependencies.synthesize(projectedRequest, evidence, operationSignal),
+ operationSignal,
+ );
governed = governClinicalAskDraft(profile, draft, evidence);
} catch (error) {
if (operationSignal.aborted) throw error;
diff --git a/src/lib/clinical-ask/response-governance.ts b/src/lib/clinical-ask/response-governance.ts
index 444d823744..a419ed3a27 100644
--- a/src/lib/clinical-ask/response-governance.ts
+++ b/src/lib/clinical-ask/response-governance.ts
@@ -7,7 +7,11 @@ import type {
ClinicalAskResponse,
ClinicalAskSection,
} from "@/lib/clinical-ask/contracts";
-import { clinicalAskModeProfile, type ClinicalAskModeProfile } from "@/lib/clinical-ask/mode-profiles";
+import {
+ clinicalAskModeProfile,
+ clinicalAskSectionTitle,
+ type ClinicalAskModeProfile,
+} from "@/lib/clinical-ask/mode-profiles";
import { sourceDirectlySupportsAnswerText } from "@/lib/rag/rag-claim-support";
import type { SearchResult } from "@/lib/types";
@@ -123,6 +127,7 @@ export function governClinicalAskDraft(
const lead = governedClaim(profile.id, draft.lead, evidenceById);
const sections: ClinicalAskSection[] = draft.sections.map((section) => ({
...section,
+ title: clinicalAskSectionTitle(profile.id, section.id),
claims: section.claims.flatMap((claim) => {
const governed = governedClaim(profile.id, claim, evidenceById);
return governed ? [governed] : [];
diff --git a/src/lib/cme/entry-version.ts b/src/lib/cme/entry-version.ts
new file mode 100644
index 0000000000..b507b76726
--- /dev/null
+++ b/src/lib/cme/entry-version.ts
@@ -0,0 +1,20 @@
+import type { CmeEntry, CmeEntryVersion } from "@/lib/cme/types";
+
+/** Matches cme_entry_edit_version; derived only from the record actually read. */
+export function cmeEntryVersion(entry: CmeEntry): CmeEntryVersion {
+ return {
+ id: entry.id,
+ date: entry.date,
+ title: entry.title,
+ reflection: entry.reflection,
+ costCents: entry.costCents,
+ routineId: entry.routineId,
+ documentId: entry.documentId,
+ sourceUrl: entry.sourceUrl ?? null,
+ buckets: [...entry.buckets],
+ formalPeerReviewHours: entry.formalPeerReviewHours ?? 0,
+ transcribed: entry.transcribed,
+ archived: Boolean(entry.archivedAt),
+ allocations: [...entry.allocations].sort((a, b) => a.category.localeCompare(b.category)),
+ };
+}
diff --git a/src/lib/cme/load-cme-page-data.ts b/src/lib/cme/load-cme-page-data.ts
index c33bda793c..1ec26c7e79 100644
--- a/src/lib/cme/load-cme-page-data.ts
+++ b/src/lib/cme/load-cme-page-data.ts
@@ -128,20 +128,26 @@ async function loadAllYears(
fetchOwnerCmeYears(admin, ownerId),
fetchCmeCertificateEntryIds(admin, ownerId),
]);
- const byYear = await Promise.all(
- years.map(async ({ id, year }) => {
- if (year === selectedYear) return selectedEntries;
- const [entries, evidenceCounts] = await Promise.all([
- fetchOwnerCmeEntries(admin, ownerId, id, options),
- fetchCmeEvidenceCounts(admin, ownerId, year),
- ]);
- return entries.map((entry) => ({
- ...entry,
- evidenceCount: evidenceCounts[entry.id] ?? 0,
- certificateCount: certificateEntryIds.has(entry.id) ? 1 : 0,
- }));
- }),
- );
+ // Keep complete cross-year records, but do not start up to 200 independent reads at once.
+ // Four years per batch means at most eight entry/evidence reads while reusing selected data.
+ const byYear: (readonly CmeEntry[])[] = [];
+ for (let from = 0; from < years.length; from += 4) {
+ const batch = await Promise.all(
+ years.slice(from, from + 4).map(async ({ id, year }) => {
+ if (year === selectedYear) return selectedEntries;
+ const [entries, evidenceCounts] = await Promise.all([
+ fetchOwnerCmeEntries(admin, ownerId, id, options),
+ fetchCmeEvidenceCounts(admin, ownerId, year),
+ ]);
+ return entries.map((entry) => ({
+ ...entry,
+ evidenceCount: evidenceCounts[entry.id] ?? 0,
+ certificateCount: certificateEntryIds.has(entry.id) ? 1 : 0,
+ }));
+ }),
+ );
+ byYear.push(...batch);
+ }
return {
allEntries: byYear.flat(),
availableYears: years.map(({ year }) => year),
diff --git a/src/lib/cme/repository.ts b/src/lib/cme/repository.ts
index d8ecd0752b..f69e52a64b 100644
--- a/src/lib/cme/repository.ts
+++ b/src/lib/cme/repository.ts
@@ -1,5 +1,6 @@
import { cpdTextPatientProblem, cpdTitlePatientProblem } from "@/lib/cme/patient-detail-check";
import { cmeEntryCreateSchema } from "@/lib/cme/schemas";
+import { cmeEntryVersion } from "@/lib/cme/entry-version";
import { cmeYearConfigurationState } from "@/lib/cme/year-configuration";
import { rowsToCmeYearClose, type CmeCloseEvaluation } from "@/lib/cme/year-close";
import { randomUUID } from "node:crypto";
@@ -71,7 +72,7 @@ export function cmeEntryToRow(entry: CmeEntry, ownerId: string, yearId: string):
* `src/lib/on-call/repository.ts` takes its joined shape apart before mapping it.
*/
export function rowToCmeEntry(row: Record, allocations: readonly CmeAllocation[]): CmeEntry {
- return {
+ const entry: CmeEntry = {
id: String(row.id),
...(Number(row.formal_peer_review_hours ?? 0) > 0
? { formalPeerReviewHours: Number(row.formal_peer_review_hours) }
@@ -88,6 +89,7 @@ export function rowToCmeEntry(row: Record, allocations: readonl
documentId: row.document_id == null ? null : String(row.document_id),
buckets: Array.isArray(row.buckets) ? row.buckets.map(String) : [],
};
+ return { ...entry, version: cmeEntryVersion(entry) };
}
/**
@@ -263,21 +265,28 @@ export async function saveCmeEntry(
if (!ownerId) throw new Error("Missing CME owner.");
const parsed = cmeEntryCreateSchema.safeParse(entry);
if (!parsed.success) throw new PublicApiError("Invalid CPD entry details or allocations.", 400);
+ if (!create && !entry.version) throw cmeRepositoryError({ message: "cme_entry_conflict" });
const payload = parsed.data;
- const { data, error } = await supabase.rpc("cme_save_entry", {
+ const { data, error } = await supabase.rpc("cme_save_entry_v2", {
p_owner_id: ownerId,
p_year_id: yearId,
p_entry_id: entry.id,
- p_entry: { ...payload, formalPeerReviewHours: entry.formalPeerReviewHours ?? 0 } as Json,
+ p_entry: {
+ ...payload,
+ formalPeerReviewHours: entry.formalPeerReviewHours ?? 0,
+ ...(!create ? { expectedVersion: entry.version } : {}),
+ } as Json,
p_create: create,
p_request_id: requestId ?? null,
});
+ if (error?.code === "PGRST202" || error?.code === "42883") throw cmeEntrySaveUnavailable();
if (error) throw cmeRepositoryError(error);
return joinedEntry(data as Record);
}
export function cmeRepositoryError(error: { message: string }): Error {
const safe: Record = {
+ cme_entry_conflict: ["This activity changed in another tab. Reload it before saving your changes.", 409],
cme_entry_archived: ["Restore this archived entry before editing or copying it.", 409],
cme_year_closed: [
"This CPD year is closed. Change one of its activities by recording an amendment from the activity's page.",
@@ -306,6 +315,12 @@ export function cmeRepositoryError(error: { message: string }): Error {
: new Error("CME storage operation failed.");
}
+function cmeEntrySaveUnavailable() {
+ return new PublicApiError("CPD saving is unavailable. Reload before trying again.", 503, {
+ code: "cme_entry_save_unavailable",
+ });
+}
+
function joinedEntry(row: Record): CmeEntry {
return rowToCmeEntry(row, (row.cme_allocations ?? []) as CmeAllocation[]);
}
@@ -635,8 +650,9 @@ export async function setCmeEntryArchived(
/**
* Close one CPD year: the database freezes a snapshot of the record under the owner lock and
- * sets `closed_at` in the same transaction. `evaluation` must agree with the rows on total
- * hours and activity count, or nothing is written (`cme_close_conflict`).
+ * sets `closed_at` in the same transaction. Every evaluation input must match the locked
+ * rows, or nothing is written (`cme_close_conflict`). The versioned RPC prevents older
+ * database instances from silently accepting a total/count-only check.
*/
export async function closeCmeYear(
supabase: AdminClient,
@@ -646,12 +662,17 @@ export async function closeCmeYear(
shortfallNote: string | null,
): Promise {
if (!ownerId) throw new Error("Missing CME owner.");
- const { error } = await supabase.rpc("cme_close_year", {
+ const { error } = await supabase.rpc("cme_close_year_v2", {
p_owner_id: ownerId,
p_year_id: yearId,
p_evaluation: JSON.parse(JSON.stringify(evaluation)) as Json,
p_shortfall_note: shortfallNote,
});
+ if (error?.code === "PGRST202" || error?.code === "42883") {
+ throw new PublicApiError("Year closing is unavailable. Nothing has been closed. Try again later.", 503, {
+ code: "cme_year_close_unavailable",
+ });
+ }
if (error) throw cmeRepositoryError(error);
}
@@ -668,12 +689,18 @@ export async function amendClosedCmeEntry(
if (!ownerId) throw new Error("Missing CME owner.");
const parsed = cmeEntryCreateSchema.safeParse(entry);
if (!parsed.success) throw new PublicApiError("Invalid CPD entry details or allocations.", 400);
- const { data, error } = await supabase.rpc("cme_amend_closed_entry", {
+ if (!entry.version) throw cmeRepositoryError({ message: "cme_entry_conflict" });
+ const { data, error } = await supabase.rpc("cme_amend_closed_entry_v2", {
p_owner_id: ownerId,
p_entry_id: entry.id,
- p_entry: { ...parsed.data, formalPeerReviewHours: entry.formalPeerReviewHours ?? 0 } as Json,
+ p_entry: {
+ ...parsed.data,
+ formalPeerReviewHours: entry.formalPeerReviewHours ?? 0,
+ expectedVersion: entry.version,
+ } as Json,
p_reason: reason,
});
+ if (error?.code === "PGRST202" || error?.code === "42883") throw cmeEntrySaveUnavailable();
if (error) throw cmeRepositoryError(error);
return joinedEntry(data as Record);
}
diff --git a/src/lib/cme/schemas.ts b/src/lib/cme/schemas.ts
index f8968df448..47bbe1aa7f 100644
--- a/src/lib/cme/schemas.ts
+++ b/src/lib/cme/schemas.ts
@@ -46,6 +46,22 @@ const entryFields = z.object({
const validCredit = (entry: { formalPeerReviewHours: number; allocations: { category: string; hours: number }[] }) =>
entry.formalPeerReviewHours <=
entry.allocations.filter((a) => a.category === "reviewing").reduce((sum, a) => sum + a.hours, 0);
+// A stored-content token must preserve exact strings rather than normalising edited input.
+export const cmeEntryVersionSchema = z.object({
+ id: z.string().uuid(),
+ date: cmeDateSchema,
+ title: z.string().max(200),
+ reflection: z.string().max(2000),
+ costCents: z.number().int().nonnegative().max(2147483647).nullable(),
+ routineId: z.string().uuid().nullable(),
+ documentId: z.string().uuid().nullable(),
+ sourceUrl: z.string().max(2000).nullable(),
+ buckets: z.array(z.string().max(80)).max(8),
+ formalPeerReviewHours: z.number().nonnegative().max(24),
+ transcribed: z.boolean(),
+ archived: z.boolean(),
+ allocations: z.array(cmeAllocationSchema).max(3),
+});
export const cmeEntryCreateSchema = entryFields
.extend({
requestId: z.string().uuid().optional(),
@@ -58,7 +74,12 @@ export const cmeEntryCreateSchema = entryFields
// A complete replace prevents omitted fields from silently erasing prior records.
export const cmeEntryUpdateSchema = entryFields
.required()
- .extend({ formalPeerReviewHours: z.number().nonnegative().max(24), sourceUrl: entryFields.shape.sourceUrl })
+ .extend({
+ formalPeerReviewHours: z.number().nonnegative().max(24),
+ sourceUrl: entryFields.shape.sourceUrl,
+ // Missing versions receive the repository's reload conflict, including older clients.
+ expectedVersion: cmeEntryVersionSchema.optional(),
+ })
.refine(validCredit, "Formal peer review credit cannot exceed reviewing hours.");
export const cmeListQuerySchema = z.object({ year: z.coerce.number().int().min(2000).max(2100).optional() });
const hours = z.number().positive().max(500);
@@ -145,5 +166,6 @@ export const cmeEntryAmendSchema = entryFields
// Required (null when there is none): an amendment states the whole record.
sourceUrl: entryFields.shape.sourceUrl.unwrap(),
amendmentReason: z.string().trim().min(3).max(1000),
+ expectedVersion: cmeEntryVersionSchema.optional(),
})
.refine(validCredit, "Formal peer review credit cannot exceed reviewing hours.");
diff --git a/src/lib/cme/types.ts b/src/lib/cme/types.ts
index 86ea003745..f0d6e44b1e 100644
--- a/src/lib/cme/types.ts
+++ b/src/lib/cme/types.ts
@@ -42,7 +42,26 @@ export type CmeRequirement = {
export type CmeAllocation = { readonly category: CmeCategory; readonly hours: number };
+/** Complete stored content a full-record editor must still be replacing. */
+export type CmeEntryVersion = {
+ readonly id: string;
+ readonly date: string;
+ readonly title: string;
+ readonly reflection: string;
+ readonly costCents: number | null;
+ readonly routineId: string | null;
+ readonly documentId: string | null;
+ readonly sourceUrl: string | null;
+ readonly buckets: readonly string[];
+ readonly formalPeerReviewHours: number;
+ readonly transcribed: boolean;
+ readonly archived: boolean;
+ readonly allocations: readonly CmeAllocation[];
+};
+
export type CmeEntry = {
+ /** Present on stored entries; required when submitting a full edit or amendment. */
+ readonly version?: CmeEntryVersion;
readonly archivedAt?: string | null;
readonly evidenceCount?: number;
/** Active certificates only; `undefined` when not counted (falls back to `evidenceCount`). */
diff --git a/src/lib/cme/year-close.ts b/src/lib/cme/year-close.ts
index 542a21d65d..ebea2da4ca 100644
--- a/src/lib/cme/year-close.ts
+++ b/src/lib/cme/year-close.ts
@@ -37,11 +37,26 @@ export type CmeCloseEvaluation = {
readonly totalHours: number;
readonly entryCount: number;
readonly requirements: readonly CmeClosedRequirementStatus[];
+ /** Canonical complete evaluator inputs, checked against locked database rows before closing. */
+ readonly inputs: {
+ readonly year: number;
+ readonly totalHours: number;
+ readonly confirmedOn: string;
+ readonly confirmedSource: string;
+ readonly requirements: CmeRequirementSet["requirements"];
+ readonly entries: readonly {
+ readonly id: string;
+ readonly buckets: readonly string[];
+ readonly formalPeerReviewHours: number;
+ readonly allocations: readonly CmeAllocation[];
+ }[];
+ };
};
/**
- * The app's reading of the year at closing. The database checks the total and the count
- * against its own rows before accepting it, so a stale screen cannot close a different record.
+ * The app's reading of the year at closing. Every evaluator input travels with the statuses.
+ * The database compares them to its locked rows before freezing the record; totals alone
+ * cannot detect a category, credit, bucket, task or target change.
*/
export function buildCmeCloseEvaluation(set: CmeRequirementSet, entries: readonly CmeEntry[]): CmeCloseEvaluation {
const active = entries.filter((entry) => !entry.archivedAt);
@@ -49,6 +64,29 @@ export function buildCmeCloseEvaluation(set: CmeRequirementSet, entries: readonl
return {
totalHours,
entryCount: active.length,
+ inputs: {
+ year: set.year,
+ totalHours: set.totalHours,
+ confirmedOn: set.confirmedOn,
+ confirmedSource: set.confirmedSource,
+ requirements: [...set.requirements]
+ .sort((a, b) => a.id.localeCompare(b.id))
+ .map((requirement) => ({
+ id: requirement.id,
+ label: requirement.label,
+ source: requirement.source,
+ spec: requirement.spec,
+ completedOn: requirement.completedOn,
+ })),
+ entries: [...active]
+ .sort((a, b) => a.id.localeCompare(b.id))
+ .map((entry) => ({
+ id: entry.id,
+ buckets: [...entry.buckets],
+ formalPeerReviewHours: entry.formalPeerReviewHours ?? 0,
+ allocations: [...entry.allocations].sort((a, b) => a.category.localeCompare(b.category)),
+ })),
+ },
requirements: statuses.map((status) => ({
requirementId: status.requirementId,
label: set.requirements.find((requirement) => requirement.id === status.requirementId)?.label ?? "",
diff --git a/src/lib/deep-memory.ts b/src/lib/deep-memory.ts
index fa650c2125..aae492ca77 100644
--- a/src/lib/deep-memory.ts
+++ b/src/lib/deep-memory.ts
@@ -1,3 +1,8 @@
+import {
+ assertLegacyMaintenanceSnapshot,
+ commitMaintenanceDeepMemory,
+ type MaintenanceDocument,
+} from "@/lib/ingestion-maintenance";
import {
parseIngestionFence,
runIngestionPersistence,
@@ -109,79 +114,22 @@ function derivedChunkAnchor(chunk: MemoryChunk) {
return `${page}-c${chunk.chunk_index}-${heading}`.slice(0, 80);
}
-async function repairMissingChunkAnchors(supabase: SupabaseClient, chunks: MemoryChunk[]) {
- const missing = chunks.filter((chunk) => chunk.anchor_id === null);
- let repaired = 0;
-
- for (let start = 0; start < missing.length; start += 10) {
- const batch = missing.slice(start, start + 10);
- await Promise.all(
- batch.map(async (chunk) => {
- const anchor = derivedChunkAnchor(chunk);
- const { error } = await supabase
- .from("document_chunks")
- .update({
- anchor_id: anchor,
- metadata: {
- ...metadataRecord(chunk.metadata),
- anchor_id: anchor,
- anchor_repaired_by: documentIntelligenceVersion,
- },
- })
- .eq("id", chunk.id)
- .is("anchor_id", null);
- if (error) throw new Error(error.message);
- chunk.anchor_id = anchor;
- chunk.metadata = {
- ...metadataRecord(chunk.metadata),
- anchor_id: anchor,
- anchor_repaired_by: documentIntelligenceVersion,
- };
- repaired += 1;
- }),
- );
- }
-
- return repaired;
-}
-
-async function stampDeepMemoryChunks(args: { supabase: SupabaseClient; documentId: string }) {
+function prepareMaintenanceChunkPatches(chunks: MemoryChunk[]) {
const stampedAt = new Date().toISOString();
-
- const { data: chunks, error: chunksError } = await args.supabase
- .from("document_chunks")
- .select("id,metadata")
- .eq("document_id", args.documentId);
- if (chunksError) throw new Error(chunksError.message);
-
- const chunksToUpdate = (chunks ?? []).filter((chunk) => {
- const record = metadataRecord(chunk.metadata);
- return record.rag_memory_version !== ragDeepMemoryVersion && record.rag_indexing_version !== ragDeepMemoryVersion;
+ return chunks.map((chunk) => {
+ const anchor = chunk.anchor_id === null ? derivedChunkAnchor(chunk) : undefined;
+ return {
+ id: chunk.id,
+ ...(anchor ? { anchor_id: anchor } : {}),
+ metadata: {
+ ...(anchor ? { anchor_id: anchor, anchor_repaired_by: documentIntelligenceVersion } : {}),
+ rag_indexing_version: ragDeepMemoryVersion,
+ rag_memory_version: ragDeepMemoryVersion,
+ rag_memory_updated_at: stampedAt,
+ document_intelligence_version: documentIntelligenceVersion,
+ },
+ };
});
-
- if (chunksToUpdate.length > 0) {
- const limit = 5;
- for (let start = 0; start < chunksToUpdate.length; start += limit) {
- const batch = chunksToUpdate.slice(start, start + limit);
- await Promise.all(
- batch.map(async (chunk) => {
- const { error } = await args.supabase
- .from("document_chunks")
- .update({
- metadata: {
- ...metadataRecord(chunk.metadata),
- rag_indexing_version: ragDeepMemoryVersion,
- rag_memory_version: ragDeepMemoryVersion,
- rag_memory_updated_at: stampedAt,
- document_intelligence_version: documentIntelligenceVersion,
- },
- })
- .eq("id", chunk.id);
- if (error) throw new Error(error.message);
- }),
- );
- }
- }
}
type DeepMemoryArtifactTable = "document_sections" | "document_memory_cards" | "document_index_units";
@@ -209,7 +157,11 @@ function artifactOwnership(
return "ambiguous";
}
-export async function assertLocalDeepMemoryOwnership(supabase: SupabaseClient, documentId: string) {
+export async function assertLocalDeepMemoryOwnership(
+ supabase: SupabaseClient,
+ documentId: string,
+ requireAssignedGeneration = false,
+) {
const tables: DeepMemoryArtifactTable[] = ["document_sections", "document_memory_cards", "document_index_units"];
const artifactResults = await Promise.all(
@@ -217,7 +169,7 @@ export async function assertLocalDeepMemoryOwnership(supabase: SupabaseClient, d
table,
result: await supabase
.from(table)
- .select("producer,artifact_generation_id,metadata")
+ .select("producer,artifact_generation_id,index_generation_id,metadata")
.eq("document_id", documentId),
})),
);
@@ -230,6 +182,9 @@ export async function assertLocalDeepMemoryOwnership(supabase: SupabaseClient, d
if (!Array.isArray(data) || data.some((row) => artifactOwnership(table, row) !== "local")) {
throw new DeepMemoryOwnershipConflictError(documentId);
}
+ if (requireAssignedGeneration && data.some((row) => row.index_generation_id === null)) {
+ throw new Error("legacy_generation_lineage_requires_fenced_reindex");
+ }
}
}
@@ -260,9 +215,11 @@ async function upsertLegacyDocumentDeepMemory(args: {
images?: MemoryImage[];
summary?: string | null;
}) {
+ const maintenanceDocument = args.document as MemoryDocument & MaintenanceDocument;
+ assertLegacyMaintenanceSnapshot(maintenanceDocument);
if (args.chunks.length === 0) throw new Error("Cannot build deep memory for a document with no chunks.");
- await assertLocalDeepMemoryOwnership(args.supabase, args.document.id);
+ await assertLocalDeepMemoryOwnership(args.supabase, args.document.id, true);
const sections = buildDocumentSections(args);
let modelProfile: ModelIndexProfile | null = null;
@@ -336,7 +293,7 @@ async function upsertLegacyDocumentDeepMemory(args: {
if (indexUnits.length > 0 && indexUnitEmbeddings.length !== indexUnits.length) {
throw new Error("OpenAI returned an unexpected index-unit embedding count.");
}
- let repairedAnchorCount = 0;
+ const repairedAnchorCount = args.chunks.filter((chunk) => chunk.anchor_id === null).length;
const indexUnitCountsByType = countDocumentIndexUnitsByType(indexUnits);
const artifactGenerationId = crypto.randomUUID();
@@ -416,9 +373,6 @@ async function upsertLegacyDocumentDeepMemory(args: {
if (error) throw new Error(error.message);
}
}
-
- repairedAnchorCount = await repairMissingChunkAnchors(args.supabase, args.chunks);
- await stampDeepMemoryChunks({ supabase: args.supabase, documentId: args.document.id });
} catch (error) {
await cleanupStagedArtifacts();
throw error;
@@ -427,43 +381,37 @@ async function upsertLegacyDocumentDeepMemory(args: {
// The RPC may have committed even when its response is lost. Never clean up
// after invocation: committed rows now share the document generation, while
// genuinely staged leftovers remain invisible and are superseded later.
- const { error: commitError } = await args.supabase.rpc("commit_document_deep_memory_generation", {
- p_document_id: args.document.id,
- p_producer: localDeepMemoryProducer,
- p_artifact_generation_id: artifactGenerationId,
- p_rag_memory_version: ragDeepMemoryVersion,
- p_document_intelligence_version: documentIntelligenceVersion,
- p_section_count: sections.length,
- p_memory_card_count: cards.length,
- p_index_unit_counts_by_type: indexUnitCountsByType,
- p_repaired_anchor_count: repairedAnchorCount,
+ const aliases = (modelProfile?.aliases ?? [])
+ .filter((alias) => alias.confidence >= 0.65)
+ .map((alias) => ({
+ alias: alias.alias,
+ canonical: alias.canonical,
+ alias_type: alias.alias_type,
+ weight: Math.max(0.5, Math.min(1.5, alias.confidence + 0.25)),
+ enabled: true,
+ metadata: {
+ source: "model_index_profile",
+ document_id: args.document.id,
+ source_chunk_ids: alias.source_chunk_ids,
+ model_index_version: modelIndexExtractionVersion,
+ },
+ }));
+ await commitMaintenanceDeepMemory({
+ supabase: args.supabase,
+ document: maintenanceDocument,
+ payload: {
+ p_chunk_patches: prepareMaintenanceChunkPatches(args.chunks),
+ p_aliases: aliases,
+ p_producer: localDeepMemoryProducer,
+ p_artifact_generation_id: artifactGenerationId,
+ p_rag_memory_version: ragDeepMemoryVersion,
+ p_document_intelligence_version: documentIntelligenceVersion,
+ p_section_count: sections.length,
+ p_memory_card_count: cards.length,
+ p_index_unit_counts_by_type: indexUnitCountsByType,
+ p_repaired_anchor_count: repairedAnchorCount,
+ },
});
- if (commitError) throw new Error(commitError.message);
-
- if (modelProfile?.aliases.length) {
- const aliases = modelProfile.aliases
- .filter((alias) => alias.confidence >= 0.65)
- .map((alias) => ({
- owner_id: args.document.owner_id ?? null,
- alias: alias.alias,
- canonical: alias.canonical,
- alias_type: alias.alias_type,
- weight: Math.max(0.5, Math.min(1.5, alias.confidence + 0.25)),
- enabled: true,
- metadata: {
- source: "model_index_profile",
- document_id: args.document.id,
- source_chunk_ids: alias.source_chunk_ids,
- model_index_version: modelIndexExtractionVersion,
- },
- }));
- if (aliases.length) {
- await args.supabase
- .from("rag_aliases")
- .insert(aliases)
- .then(undefined, () => undefined);
- }
- }
return { sections, memoryCards: cards, indexUnits, modelProfile };
}
diff --git a/src/lib/document-enrichment.ts b/src/lib/document-enrichment.ts
index fac8c3e75f..09ea84d643 100644
--- a/src/lib/document-enrichment.ts
+++ b/src/lib/document-enrichment.ts
@@ -1,3 +1,8 @@
+import {
+ replaceMaintenanceArtifacts,
+ assertLegacyMaintenanceSnapshot,
+ type MaintenanceDocument,
+} from "@/lib/ingestion-maintenance";
import {
IngestionAttemptError,
parseIngestionFence,
@@ -590,6 +595,7 @@ export async function upsertDocumentEnrichment(args: {
supabase: SupabaseClient;
document: Pick & {
metadata?: ClinicalDocument["metadata"];
+ updated_at?: string;
};
chunks: EnrichmentChunk[];
images?: EnrichmentImage[];
@@ -615,10 +621,12 @@ async function upsertLegacyDocumentEnrichment(args: {
supabase: SupabaseClient;
document: Pick & {
metadata?: ClinicalDocument["metadata"];
+ updated_at?: string;
};
chunks: EnrichmentChunk[];
images?: EnrichmentImage[];
}) {
+ assertLegacyMaintenanceSnapshot(args.document as MaintenanceDocument);
let enrichment: GeneratedSummary;
try {
enrichment = await generateDocumentEnrichment(args);
@@ -668,12 +676,12 @@ async function upsertLegacyDocumentEnrichment(args: {
enrichment_prompt_chunk_ids: selectedPromptChunks.chunks.map((chunk) => chunk.id),
};
- const { data: summary, error: summaryError } = await args.supabase
- .from("document_summaries")
- .upsert(
- {
- document_id: args.document.id,
- owner_id: args.document.owner_id ?? null,
+ const result = await replaceMaintenanceArtifacts({
+ supabase: args.supabase,
+ document: args.document as MaintenanceDocument,
+ operation: "enrichment",
+ payload: {
+ summary: {
summary: enrichment.summary,
clinical_specifics: enrichment.clinical_specifics,
source_chunk_ids: sourceChunkIds,
@@ -683,32 +691,8 @@ async function upsertLegacyDocumentEnrichment(args: {
generated_at: enrichedAt,
updated_at: enrichedAt,
},
- { onConflict: "document_id" },
- )
- .select("*")
- .single();
-
- if (summaryError) throw new Error(summaryError.message);
-
- await args.supabase.from("document_labels").delete().eq("document_id", args.document.id).eq("source", "generated");
- if (enrichment.labels.length > 0) {
- const { error: labelsError } = await args.supabase.from("document_labels").insert(
- enrichment.labels.map((label) => ({
- document_id: args.document.id,
- owner_id: args.document.owner_id ?? null,
- ...label,
- source: "generated",
- metadata: generatedMetadata,
- })),
- );
- if (labelsError) throw new Error(labelsError.message);
- }
-
- const { error: documentMetadataError } = await args.supabase
- .from("documents")
- .update({
- metadata: {
- ...metadataRecord(args.document.metadata),
+ labels: enrichment.labels.map((label) => ({ ...label, metadata: generatedMetadata })),
+ metadata_patch: {
rag_enrichment_version: ragEnrichmentVersion,
rag_indexing_version: ragEnrichmentVersion,
rag_memory_version: ragEnrichmentVersion,
@@ -717,13 +701,11 @@ async function upsertLegacyDocumentEnrichment(args: {
...organization.metadata,
...coverageMetadata,
},
- })
- .eq("id", args.document.id);
-
- if (documentMetadataError) throw new Error(documentMetadataError.message);
-
- return { summary: summary as DocumentSummary, labels: enrichment.labels };
+ },
+ });
+ return { summary: result.summary as DocumentSummary, labels: enrichment.labels };
}
+
export function assertSourceAnchoredSummaryPreflight(args: {
documentMetadata: unknown;
indexGenerationId: unknown;
@@ -903,6 +885,7 @@ async function upsertFencedDocumentEnrichment(args: {
signal?: AbortSignal;
document: Pick & {
metadata?: ClinicalDocument["metadata"];
+ updated_at?: string;
};
indexGenerationId: string;
chunks: EnrichmentChunk[];
diff --git a/src/lib/env.ts b/src/lib/env.ts
index 2cc19bd148..fabe05bc88 100644
--- a/src/lib/env.ts
+++ b/src/lib/env.ts
@@ -48,7 +48,7 @@ export function parseClinicalAskDisabledModes(value: unknown): string[] {
const envSchema = z
.object({
- NEXT_PUBLIC_SUPABASE_URL: z.string().url().optional(),
+ NEXT_PUBLIC_SUPABASE_URL: z.preprocess(coerceBlankEnv, z.string().url().optional()),
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: z.string().optional(),
NEXT_PUBLIC_SUPABASE_STAGING_PROJECT_REF: z.string().optional(),
NEXT_PUBLIC_SUPABASE_STAGING_PROJECT_NAME: z.string().optional(),
@@ -72,7 +72,7 @@ const envSchema = z
.regex(/^[0-9a-f]{64}$/)
.optional(),
),
- SUPABASE_DB_URL: z.string().url().optional(),
+ SUPABASE_DB_URL: z.preprocess(coerceBlankEnv, z.string().url().optional()),
// Blank means unset (offline wrappers blank inherited secrets); an unset secret keeps the
// deep probe closed, and a non-blank value must still be at least 16 characters.
HEALTH_DEEP_PROBE_SECRET: z.preprocess(coerceBlankEnv, z.string().min(16).optional()),
diff --git a/src/lib/ingestion-maintenance.ts b/src/lib/ingestion-maintenance.ts
new file mode 100644
index 0000000000..dae9800c5f
--- /dev/null
+++ b/src/lib/ingestion-maintenance.ts
@@ -0,0 +1,121 @@
+import type { SupabaseClient } from "@supabase/supabase-js";
+import type { Json } from "@/lib/supabase/database.types";
+
+export type MaintenanceDocument = {
+ id: string;
+ owner_id: string | null;
+ updated_at: string;
+ metadata: unknown;
+};
+
+export class IngestionMaintenanceConflict extends Error {
+ constructor() {
+ super("Document changed during maintenance; reload and recompute before retrying.");
+ this.name = "IngestionMaintenanceConflict";
+ }
+}
+
+export function maintenanceMetadata(value: unknown): Record {
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {};
+}
+
+/** The original snapshot is authority for derived work, including failure/status writes.
+ * Never refresh its version after a conflict: doing so would stamp stale work onto a new generation.
+ */
+export async function patchMaintenanceDocument(args: {
+ supabase: SupabaseClient;
+ document: MaintenanceDocument;
+ patch: Record;
+ imageCount?: number;
+}) {
+ const { document } = args;
+ if (!document.updated_at) throw new IngestionMaintenanceConflict();
+ if (Object.prototype.hasOwnProperty.call(args.patch, "index_generation_id")) {
+ throw new Error("Maintenance cannot change the committed generation.");
+ }
+ const metadata = { ...maintenanceMetadata(document.metadata), ...args.patch };
+ let query = args.supabase
+ .from("documents")
+ .update({ metadata: metadata as Json, ...(args.imageCount === undefined ? {} : { image_count: args.imageCount }) })
+ .eq("id", document.id)
+ .eq("updated_at", document.updated_at);
+ query = document.owner_id === null ? query.is("owner_id", null) : query.eq("owner_id", document.owner_id);
+ const generation = maintenanceMetadata(document.metadata).index_generation_id;
+ query =
+ typeof generation === "string"
+ ? query.eq("index_generation_id", generation)
+ : query.is("index_generation_id", null);
+ const { data, error } = await query.select("updated_at,metadata").maybeSingle();
+ if (error) throw new Error(error.message);
+ if (!data?.updated_at) throw new IngestionMaintenanceConflict();
+ document.updated_at = data.updated_at;
+ document.metadata = data.metadata;
+ return data;
+}
+
+export function assertLegacyMaintenanceSnapshot(document: MaintenanceDocument) {
+ if (!document.updated_at) throw new IngestionMaintenanceConflict();
+ const metadata = maintenanceMetadata(document.metadata);
+ if (
+ [
+ "admission_event_id",
+ "public_source_version_id",
+ "source_catalogue_key",
+ "public_source_activation_event_id",
+ ].some((key) => Object.hasOwn(metadata, key)) ||
+ metadata.corpus_scope === "australian_public"
+ ) {
+ throw new Error(
+ "Managed evidence requires its fenced worker reindex; legacy maintenance cannot replace retained provenance.",
+ );
+ }
+}
+
+/** Replacement happens only after all provider/preparation work has succeeded.
+ * A single database transaction validates the original snapshot and replaces its public projection.
+ */
+export async function replaceMaintenanceArtifacts(args: {
+ supabase: SupabaseClient;
+ document: MaintenanceDocument;
+ operation: "labels" | "enrichment" | "visual" | "images" | "stamp" | "embedding-fields" | "registry";
+ payload: Record;
+}) {
+ assertLegacyMaintenanceSnapshot(args.document);
+ const generation = maintenanceMetadata(args.document.metadata).index_generation_id;
+ const { data, error } = await args.supabase.rpc("replace_document_maintenance_artifacts", {
+ p_document_id: args.document.id,
+ p_owner_id: args.document.owner_id,
+ p_expected_updated_at: args.document.updated_at,
+ p_expected_generation_id: typeof generation === "string" ? generation : null,
+ p_operation: args.operation,
+ p_payload: args.payload,
+ });
+ if (error) throw new Error(error.message);
+ if (!data || typeof data !== "object" || typeof data.updated_at !== "string")
+ throw new IngestionMaintenanceConflict();
+ args.document.updated_at = data.updated_at;
+ args.document.metadata = data.metadata;
+ return data as { updated_at: string; metadata: unknown; summary?: unknown };
+}
+
+export async function commitMaintenanceDeepMemory(args: {
+ supabase: SupabaseClient;
+ document: MaintenanceDocument;
+ payload: Record;
+}) {
+ assertLegacyMaintenanceSnapshot(args.document);
+ const generation = maintenanceMetadata(args.document.metadata).index_generation_id;
+ const { data, error } = await args.supabase.rpc("commit_document_maintenance_deep_memory", {
+ ...args.payload,
+ p_document_id: args.document.id,
+ p_owner_id: args.document.owner_id,
+ p_expected_updated_at: args.document.updated_at,
+ p_expected_generation_id: typeof generation === "string" ? generation : null,
+ });
+ if (error) throw new Error(error.message);
+ if (!data || typeof data !== "object" || typeof data.updated_at !== "string")
+ throw new IngestionMaintenanceConflict();
+ args.document.updated_at = data.updated_at;
+ args.document.metadata = data.metadata;
+ return data;
+}
diff --git a/src/lib/ingestion.ts b/src/lib/ingestion.ts
index e7ffbeb3ae..b2352a4d3c 100644
--- a/src/lib/ingestion.ts
+++ b/src/lib/ingestion.ts
@@ -46,14 +46,11 @@ export function ingestionJobRetryRejectionReason(status: string | null | undefin
return null;
}
-// Audit R15/R16: the retry route must NOT demote an already-indexed document to
-// `queued`. A queued document takes the worker's non-atomic path, which runs
-// reset_document_index at job start and deletes the entire live committed index
-// hours before any replacement commit — so a transient failure of a healthy
-// indexed document would otherwise destroy its clinical index. Indexed
-// documents keep their status (atomic reindex: the old index stays live until
-// the new commit swaps the generation); only non-indexed documents are
-// re-queued. Either way we clear error_message and stamp the rollback fence.
+// Audit R15/R16: retry preserves an indexed document's status and committed
+// generation while the fenced worker builds its replacement. Only non-indexed
+// documents are re-queued. The worker resets only its own uncommitted attempt
+// artifacts; the retired generation-blind reset is unavailable. Either way we
+// clear error_message and stamp the rollback fence.
export function retryDocumentQueueUpdate(args: { documentStatus: string | null | undefined; fenceStamp: string }): {
status?: "queued";
error_message: null;
diff --git a/src/lib/on-call/api-schemas.ts b/src/lib/on-call/api-schemas.ts
index 03b70d0ba6..6403e85e88 100644
--- a/src/lib/on-call/api-schemas.ts
+++ b/src/lib/on-call/api-schemas.ts
@@ -1,7 +1,7 @@
import { onCallEntrySchema } from "@/lib/on-call/entry-model";
-/** Create bodies carry no `id` — the server generates it. `view` is virtual, never accepted from writes. */
-export const createOnCallEntrySchema = onCallEntrySchema.omit({ id: true, isOwn: true, view: true });
+/** Create bodies carry no `id` — the server generates it. `view` and `updatedAt` are server metadata, never accepted from writes. */
+export const createOnCallEntrySchema = onCallEntrySchema.omit({ id: true, isOwn: true, view: true, updatedAt: true });
/**
* Update bodies must be COMPLETE.
diff --git a/src/lib/on-call/entry-model.ts b/src/lib/on-call/entry-model.ts
index 6bca36f890..beda272e07 100644
--- a/src/lib/on-call/entry-model.ts
+++ b/src/lib/on-call/entry-model.ts
@@ -455,6 +455,8 @@ export const onCallEntrySchema = z
includeOnCard: z.boolean().default(false),
sortOrder: z.number().int().default(0),
lastVerifiedAt: z.string().nullable().default(null),
+ /** Read-only raw PostgreSQL version; retain microseconds for conditional verification. */
+ updatedAt: z.string().datetime({ offset: true }).optional(),
/**
* Whether the signed-in reader owns this row, set by the server on read
* and write responses — never stored, never accepted from a client (the
@@ -486,6 +488,41 @@ export function onCallEntryIsEditable(entry: Pick): boolea
export type OnCallEntry = z.infer;
+/** Compare everything the person reviewed, independent of object-key order. */
+export function onCallEntryVerificationContentMatches(left: OnCallEntry, right: OnCallEntry): boolean {
+ const content = (entry: OnCallEntry) =>
+ JSON.stringify(
+ { ...onCallEntrySchema.parse(entry), lastVerifiedAt: null, updatedAt: null },
+ (_key, value: unknown) =>
+ value && typeof value === "object" && !Array.isArray(value)
+ ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)))
+ : value,
+ );
+ return content(left) === content(right);
+}
+
+/** Apply only a verification stamp for the exact content the person reviewed.
+ * Delayed responses must not restore edited/deleted content or move a newer
+ * verification backwards. */
+export function mergeOnCallEntryVerification(
+ current: OnCallEntry | undefined,
+ reviewed: OnCallEntry,
+ verified: OnCallEntry,
+): OnCallEntry | null {
+ if (
+ !current ||
+ !onCallEntryVerificationContentMatches(current, reviewed) ||
+ !onCallEntryVerificationContentMatches(verified, reviewed)
+ )
+ return null;
+ const stamp = verified.lastVerifiedAt === null ? NaN : Date.parse(verified.lastVerifiedAt);
+ if (!Number.isFinite(stamp)) return null;
+ const currentStamp = current.lastVerifiedAt === null ? NaN : Date.parse(current.lastVerifiedAt);
+ return Number.isFinite(currentStamp) && currentStamp >= stamp
+ ? current
+ : { ...current, lastVerifiedAt: verified.lastVerifiedAt, updatedAt: verified.updatedAt };
+}
+
/**
* The one fact an On Call surface may state about a document it links to: its
* title and its date. Declared here in the domain layer rather than beside the
diff --git a/src/lib/on-call/entry-store.ts b/src/lib/on-call/entry-store.ts
index 8c2e9c2ad6..90f92beb4d 100644
--- a/src/lib/on-call/entry-store.ts
+++ b/src/lib/on-call/entry-store.ts
@@ -30,7 +30,7 @@ import {
removeLegacyOnCallEntryCaches,
} from "@/lib/on-call/entry-cache-keys";
import { onCallEntrySchema, type OnCallEntry } from "@/lib/on-call/entry-model";
-import { sharedGet } from "@/lib/shared-get";
+import { forgetSharedGets, sharedGet } from "@/lib/shared-get";
export { clearOnCallEntryCache, onCallEntryCacheChangedEvent, onCallEntryCacheStorageKey, peekOnCallEntrySessionEpoch };
@@ -91,6 +91,9 @@ function devicePayload(payload: CachedOnCallEntries): string | null {
// Private entries belong to the current session, never durable device storage.
let sessionCache: string | null = null;
let sessionCacheEpoch = peekOnCallEntrySessionEpoch();
+// Local writes supersede list requests started before them. Shared GET
+// subscribers may all accept the same response without superseding each other.
+let cacheRevision = 0;
const cachedEntriesSchema = z
.object({
@@ -133,8 +136,15 @@ export function readCachedOnCallEntries(): CachedOnCallEntries | null {
* device copy); callers still have the freshly fetched entries for this render
* even when it did not.
*/
-export function cacheOnCallEntries(entries: OnCallEntry[]): boolean {
- if (typeof window === "undefined") return false;
+export function cacheOnCallEntries(
+ entries: OnCallEntry[],
+ expectedEpoch = peekOnCallEntrySessionEpoch(),
+ expectedRevision?: number,
+): boolean {
+ if (typeof window === "undefined" || expectedEpoch !== peekOnCallEntrySessionEpoch()) return false;
+ if (expectedRevision !== undefined && expectedRevision !== cacheRevision) return false;
+ // No writer may replace the last good list with an unreadable payload.
+ if (!z.array(onCallEntrySchema).safeParse(entries).success) return false;
// Example rows shown by the example data switch are display only. A screen
// that writes back the list it was given (verify all, an edit's upsert) must
// never put them in the account's cache, so the whole write is refused and
@@ -143,6 +153,11 @@ export function cacheOnCallEntries(entries: OnCallEntry[]): boolean {
try {
const payload: CachedOnCallEntries = { entries, savedAt: new Date().toISOString() };
sessionCache = JSON.stringify(payload);
+ if (expectedRevision === undefined) {
+ cacheRevision += 1;
+ // A newly mounted reader must not join a GET from before this write.
+ forgetSharedGets("/api/on-call/entries");
+ }
sessionCacheEpoch = peekOnCallEntrySessionEpoch();
// The only `localStorage` write of On Call entries.
const device = devicePayload(payload);
@@ -170,11 +185,12 @@ export function cacheOnCallEntries(entries: OnCallEntry[]): boolean {
export function updateCachedOnCallEntries(
fallback: readonly OnCallEntry[],
update: (current: OnCallEntry[]) => OnCallEntry[],
+ expectedEpoch: number,
): boolean {
- if (typeof window === "undefined") return false;
+ if (typeof window === "undefined" || expectedEpoch !== peekOnCallEntrySessionEpoch()) return false;
if (fallback.some((entry) => shownExampleEntries.has(entry))) return false;
const current = readCachedOnCallEntries()?.entries ?? [...fallback];
- return cacheOnCallEntries(update(current));
+ return cacheOnCallEntries(update(current), expectedEpoch);
}
function scrubPersistedOnCallCache(): void {
@@ -388,6 +404,7 @@ export function useStoredOnCallEntries(): OnCallEntriesState {
useEffect(() => {
const epochAtStart = peekOnCallEntrySessionEpoch();
+ const revisionAtStart = cacheRevision;
let cancelled = false;
const controller = new AbortController();
@@ -402,17 +419,17 @@ export function useStoredOnCallEntries(): OnCallEntriesState {
const parsedResponse = onCallEntriesResponseSchema.safeParse(rawBody);
if (!parsedResponse.success) throw new Error("On Call entries response was malformed.");
- const entries = parsedResponse.data.entries
- .map((entry) => onCallEntrySchema.safeParse(entry))
- .filter((result): result is { success: true; data: OnCallEntry } => result.success)
- .map((result) => result.data)
- .filter(
- (entry) =>
- !parsedResponse.data.signedOut ||
- (!entry.isPersonal && !mayContainOnCallCompliance(entry.section, entry.details)),
- );
-
- if (cancelled || peekOnCallEntrySessionEpoch() !== epochAtStart) return;
+ const parsedEntries = z.array(onCallEntrySchema).safeParse(parsedResponse.data.entries);
+ if (!parsedEntries.success) throw new Error("On Call entries response was malformed.");
+ // A successful list is authoritative. Never publish a partial parse as
+ // a withdrawal: one incompatible row makes this a failed refresh.
+ const entries = parsedEntries.data.filter(
+ (entry) =>
+ !parsedResponse.data.signedOut ||
+ (!entry.isPersonal && !mayContainOnCallCompliance(entry.section, entry.details)),
+ );
+
+ if (cancelled || peekOnCallEntrySessionEpoch() !== epochAtStart || cacheRevision !== revisionAtStart) return;
setIsOffline(false);
setLoadError(null);
setSignedOut(parsedResponse.data.signedOut);
@@ -423,7 +440,7 @@ export function useStoredOnCallEntries(): OnCallEntriesState {
// request may fall back to cache. Synthetic preview is an explicit,
// separately marked choice and is not overwritten by real responses.
if (!isOnCallDemoPreviewActive()) {
- cacheOnCallEntries(entries);
+ cacheOnCallEntries(entries, epochAtStart, revisionAtStart);
}
} catch (error) {
// Abort is the account-transition path, not a network failure.
diff --git a/src/lib/on-call/repository.ts b/src/lib/on-call/repository.ts
index 734ea7e865..78fd278739 100644
--- a/src/lib/on-call/repository.ts
+++ b/src/lib/on-call/repository.ts
@@ -37,7 +37,7 @@ export async function assertValidLinkedDocumentIds(
}
const ROW_COLUMNS =
- "id, section, slug, title, subtitle, body, details, linked_document_ids, tags, is_personal, include_on_card, sort_order, last_verified_at";
+ "id, section, slug, title, subtitle, body, details, linked_document_ids, tags, is_personal, include_on_card, sort_order, last_verified_at, updated_at";
export function rowToOnCallEntry(
row: Record,
@@ -59,6 +59,8 @@ export function rowToOnCallEntry(
includeOnCard: row.include_on_card ?? false,
sortOrder: row.sort_order ?? 0,
lastVerifiedAt: (row.last_verified_at as string | null) ?? null,
+ // Do not round through Date: PostgreSQL's microseconds are the write precondition.
+ ...(typeof row.updated_at === "string" ? { updatedAt: row.updated_at } : {}),
});
// A row whose details do not match its section is shown without them rather
// than with a half-parsed shape a renderer would have to guess at.
diff --git a/src/lib/openai.ts b/src/lib/openai.ts
index f2aeecabba..20c5359454 100644
--- a/src/lib/openai.ts
+++ b/src/lib/openai.ts
@@ -147,6 +147,8 @@ export async function createClinicalAskWebSearchResponse(args: {
const request = {
model: env.OPENAI_ANSWER_MODEL,
store: false,
+ max_output_tokens: env.OPENAI_MAX_OUTPUT_TOKENS,
+ max_tool_calls: 1,
input: args.input,
tools: [
{
diff --git a/src/lib/roster/calendar-link-fetch.ts b/src/lib/roster/calendar-link-fetch.ts
index 2895af90d0..304eaef920 100644
--- a/src/lib/roster/calendar-link-fetch.ts
+++ b/src/lib/roster/calendar-link-fetch.ts
@@ -100,10 +100,22 @@ export function normaliseCalendarLink(raw: string): URL {
return url;
}
-async function checkedAddress(url: URL, resolve: LinkResolver): Promise {
- const addresses = await resolve(url.hostname).catch(() => {
- throw new CalendarLinkError("http_error");
+async function checkedAddress(url: URL, resolve: LinkResolver, signal: AbortSignal): Promise {
+ if (signal.aborted) throw new CalendarLinkError("timeout");
+ let rejectAbort: (reason: CalendarLinkError) => void = () => undefined;
+ const onAbort = () => rejectAbort(new CalendarLinkError("timeout"));
+ const aborted = new Promise((_resolve, reject) => {
+ rejectAbort = reject;
+ signal.addEventListener("abort", onAbort, { once: true });
});
+ let addresses: Address[];
+ try {
+ // DNS itself cannot be cancelled, but the caller's deadline must still
+ // settle. Promise.race owns a later resolver rejection as well.
+ addresses = await Promise.race([resolve(url.hostname), aborted]);
+ } finally {
+ signal.removeEventListener("abort", onAbort);
+ }
if (addresses.length === 0 || !addresses.every((entry) => isGlobalPublicAddress(entry.address))) {
throw new CalendarLinkError("private_address");
}
@@ -121,7 +133,8 @@ export async function fetchCalendarLink(
try {
let url = normaliseCalendarLink(raw);
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
- const address = await checkedAddress(url, resolve);
+ const address = await checkedAddress(url, resolve, controller.signal);
+ if (controller.signal.aborted) throw new CalendarLinkError("timeout");
const response = await request({ url, address, signal: controller.signal });
if (response.status >= 300 && response.status < 400 && response.location) {
response.cancel();
diff --git a/src/lib/supabase/auth.ts b/src/lib/supabase/auth.ts
index 1bd7b4574f..da771ed678 100644
--- a/src/lib/supabase/auth.ts
+++ b/src/lib/supabase/auth.ts
@@ -269,10 +269,13 @@ export async function resolveOptionalAuthentication(
return bearerUser ? { status: "valid", user: bearerUser } : { status: "invalid" };
}
- // Fast path: Forwarded verified claims from Next.js proxy
+ // Locally verified claims retain the read fast path. Consequential optional
+ // requests need the same live account check as required-auth saves.
const proxyUser = extractProxyAuthenticatedUser(request);
if (proxyUser) {
- return { status: "valid", user: proxyUser };
+ if (!isStateChangingRequest(request)) return { status: "valid", user: proxyUser };
+ const freshUser = await getFreshRequestUser(request, supabase);
+ return freshUser ? { status: "valid", user: freshUser } : { status: "invalid" };
}
const currentCookieToken = extractCurrentCookieSessionAccessToken(request);
@@ -306,6 +309,11 @@ function usedProxyClaims(request: Request): boolean {
return !request.headers.has("authorization") && extractProxyAuthenticatedUser(request) !== null;
}
+async function getFreshRequestUser(request: Request, supabase: AdminClient): Promise {
+ const token = extractSessionAccessToken(request);
+ return token ? getUserFromAccessToken(supabase, token) : getUserFromRequestCookies(request);
+}
+
export async function requireAuthenticatedUser(
request: Request,
supabase: AdminClient,
@@ -318,15 +326,10 @@ export async function requireAuthenticatedUser(
);
}
let { user } = authentication;
- if (requirement.administrator || (isStateChangingRequest(request) && usedProxyClaims(request))) {
- // Administrator routes never trust proxy-forwarded claims. A missing live
- // Auth user fails closed (401) instead of keeping a signed header. Saves and
- // deletes recheck the same way: the proxy verifies the access token locally,
- // so a signed-out or blocked session would otherwise keep writing until the
- // token expires, up to an hour later (PP-04, 26 Oct 2026).
- const token = extractSessionAccessToken(request);
- const freshUser =
- (token ? await getUserFromAccessToken(supabase, token) : null) ?? (await getUserFromRequestCookies(request));
+ if (requirement.administrator && !isStateChangingRequest(request) && usedProxyClaims(request)) {
+ // All other paths were checked live by optional authentication already.
+ // Administrator reads also require fresh account metadata.
+ const freshUser = await getFreshRequestUser(request, supabase);
if (!freshUser) {
throw new AuthenticationError();
}
diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts
index 10a4ef71d4..7c11d79cb6 100644
--- a/src/lib/supabase/database.types.ts
+++ b/src/lib/supabase/database.types.ts
@@ -928,6 +928,7 @@ export type Database = {
height: number | null;
id: string;
image_hash: string | null;
+ index_generation_id: string | null;
image_quality_score: number | null;
image_type: string;
labels: string[];
@@ -956,6 +957,7 @@ export type Database = {
height?: number | null;
id?: string;
image_hash?: string | null;
+ index_generation_id?: string | null;
image_quality_score?: number | null;
image_type?: string;
labels?: string[];
@@ -984,6 +986,7 @@ export type Database = {
height?: number | null;
id?: string;
image_hash?: string | null;
+ index_generation_id?: string | null;
image_quality_score?: number | null;
image_type?: string;
labels?: string[];
@@ -5216,6 +5219,25 @@ export type Database = {
cme_guard_evidence_insert: { Args: never; Returns: unknown };
cme_guard_archived_entry: { Args: never; Returns: unknown };
on_call_service_command: { Args: { p_actor_id: string; p_service_id: string | null; p_action: string; p_payload: Json }; Returns: Json };
+ teaching_create_epa_magic_link_v1: {
+ Args: { p_registrar_id: string; p_link: Json };
+ Returns: Json;
+ };
+ teaching_find_supervisor_overlap_v1: {
+ Args: { p_supervisor_id: string; p_starts_at: string; p_ends_at: string; p_exclude_id: string; p_before_created_at?: string | null; p_before_id?: string | null };
+ Returns: {
+ id: string;
+ registrar_id: string;
+ assessor_id: string | null;
+ category: string;
+ title: string;
+ ratings: Json;
+ feedback: string | null;
+ status: string;
+ created_at: string;
+ updated_at: string;
+ }[];
+ };
teaching_command: { Args: { p_actor_id: string; p_service_id: string | null; p_action: string; p_payload: Json }; Returns: Json };
teaching_depth_command: { Args: { p_actor_id: string; p_service_id: string; p_action: string; p_payload: Json }; Returns: Json };
teaching_whats_on_command: { Args: { p_actor_id: string; p_service_id: string | null; p_action: string; p_payload: Json }; Returns: Json };
@@ -5228,6 +5250,34 @@ export type Database = {
Args: { p_owner_id: string; p_year_id: string; p_evaluation: Json; p_shortfall_note?: string | null };
Returns: Json;
};
+ cme_close_year_v2: {
+ Args: { p_owner_id: string; p_year_id: string; p_evaluation: Json; p_shortfall_note?: string | null };
+ Returns: Json;
+ };
+ enqueue_import_document: {
+ Args: { p_document_id: string; p_owner_id: string; p_content_hash: string; p_batch_id: string; p_force: boolean; p_max_attempts: number };
+ Returns: Json;
+ };
+ replace_document_maintenance_artifacts: {
+ Args: { p_document_id: string; p_owner_id: string | null; p_expected_updated_at: string; p_expected_generation_id: string | null; p_operation: string; p_payload: Json };
+ Returns: Json;
+ };
+ commit_document_maintenance_deep_memory: {
+ Args: { p_document_id: string; p_owner_id: string | null; p_expected_updated_at: string; p_expected_generation_id: string | null; p_chunk_patches: Json; p_aliases: Json; p_producer: string; p_artifact_generation_id: string; p_rag_memory_version: string; p_document_intelligence_version: string; p_section_count: number; p_memory_card_count: number; p_index_unit_counts_by_type: Json; p_repaired_anchor_count?: number };
+ Returns: Json;
+ };
+ cme_entry_edit_version: {
+ Args: { p_owner_id: string; p_entry_id: string };
+ Returns: Json;
+ };
+ cme_save_entry_v2: {
+ Args: { p_owner_id: string; p_year_id: string; p_entry_id: string; p_entry: Json; p_create: boolean; p_request_id?: string | null };
+ Returns: Json;
+ };
+ cme_amend_closed_entry_v2: {
+ Args: { p_owner_id: string; p_entry_id: string; p_entry: Json; p_reason: string };
+ Returns: Json;
+ };
cme_amend_closed_entry: {
Args: { p_owner_id: string; p_entry_id: string; p_entry: Json; p_reason: string };
Returns: Json;
@@ -6330,10 +6380,6 @@ export type Database = {
status: string;
}[];
};
- reset_document_index: {
- Args: { p_document_id: string };
- Returns: undefined;
- };
run_all_visual_eval_cases: { Args: { p_limit?: number }; Returns: Json };
run_visual_eval_case: {
Args: { p_case_id: string; p_limit?: number };
diff --git a/src/lib/teaching/assessments/magic-link-repository.ts b/src/lib/teaching/assessments/magic-link-repository.ts
index 6577735aeb..b40f5bae80 100644
--- a/src/lib/teaching/assessments/magic-link-repository.ts
+++ b/src/lib/teaching/assessments/magic-link-repository.ts
@@ -1,3 +1,4 @@
+import { z } from "zod";
import { logger } from "@/lib/logger";
import { PublicApiError } from "@/lib/http";
import { EPAS, epa as getEpaContent, type EpaNumber } from "@/lib/teaching/assessments/content";
@@ -26,7 +27,7 @@ import {
/** A doctor may have this many links waiting at once, so a loop cannot fill the table. */
export const EPA_MAGIC_LINK_MAX_PENDING = 20;
-/** The most links a doctor's list returns, newest first. */
+/** One owner-history page; active waiting links are included separately so they remain cancellable. */
const LIST_LIMIT = 50;
export type EpaMagicLinkRow = {
@@ -53,6 +54,7 @@ type QueryResult = PromiseLike<{ data: T | null; error: DbErrorLike | null; c
type FilterBuilder = QueryResult & {
eq(column: string, value: string): FilterBuilder;
gt(column: string, value: string): FilterBuilder;
+ or(filter: string): FilterBuilder;
order(column: string, options?: { ascending?: boolean }): FilterBuilder;
limit(count: number): FilterBuilder;
select(columns?: string, options?: { count?: "exact"; head?: boolean }): FilterBuilder;
@@ -61,6 +63,10 @@ type FilterBuilder = QueryResult & {
};
export type EpaMagicLinksTableClient = {
+ rpc(
+ name: "teaching_create_epa_magic_link_v1",
+ args: { p_registrar_id: string; p_link: Record },
+ ): QueryResult;
from(table: "teaching_epa_magic_links"): {
select(columns?: string, options?: { count?: "exact"; head?: boolean }): FilterBuilder;
insert(values: Omit): {
@@ -87,6 +93,30 @@ export type EpaMagicLinkSummary = {
readonly submission: EpaMagicLinkCompletedSubmission | null;
};
+export type EpaMagicLinkPage = {
+ readonly links: readonly EpaMagicLinkSummary[];
+ readonly nextCursor: string | null;
+};
+
+const cursorSchema = z.object({ created_at: z.string().datetime({ offset: true }), id: z.string().uuid() }).strict();
+type LinkCursor = z.infer;
+
+function parseCursor(value: string | null): LinkCursor | null {
+ if (value === null) return null;
+ try {
+ if (value.length > 256) throw new Error("cursor too large");
+ return cursorSchema.parse(JSON.parse(value));
+ } catch {
+ throw new PublicApiError("This link-history page is invalid. Reload your links.", 400, {
+ code: "invalid_link_cursor",
+ });
+ }
+}
+
+function cursorFor(row: EpaMagicLinkRow): string {
+ return JSON.stringify({ created_at: row.created_at, id: row.id });
+}
+
/** What the supervisor's page receives. No ids, no hash, no account details. */
export type EpaMagicLinkPublicRecord = Omit;
@@ -189,10 +219,9 @@ export class DurableEpaMagicLinkRepository {
const tokenHash = hashEpaMagicToken(token);
// Builds the same record the in-memory story uses, so the patient-detail and EPA checks match.
const record = buildMagicLinkRecord(tokenHash, { ...input, now, ttlDays: EPA_MAGIC_LINK_TTL_DAYS });
- const { data, error } = await this.db
- .from("teaching_epa_magic_links")
- .insert({
- registrar_id: registrarId,
+ const { data, error } = await this.db.rpc("teaching_create_epa_magic_link_v1", {
+ p_registrar_id: registrarId,
+ p_link: {
token_hash: tokenHash,
epa_number: record.epaNumber,
trainee_name: record.traineeName,
@@ -202,23 +231,72 @@ export class DurableEpaMagicLinkRepository {
status: "pending",
expires_at: record.expiresAt,
updated_at: now.toISOString(),
- })
- .select(COLUMNS)
- .single();
+ },
+ });
+ if (error?.message === "magic_link_limit_reached") {
+ throw new EpaMagicLinkError(
+ `You have ${EPA_MAGIC_LINK_MAX_PENDING} links waiting. Cancel one before sending another.`,
+ "magic_link_limit_reached",
+ 409,
+ );
+ }
if (error || !data) throw databaseError(error);
logger.info("epa_magic_link_created", { epaNumber: record.epaNumber, expiresAt: record.expiresAt });
return { token, urlPath: `/teaching/assessments/respond/${token}`, link: rowToSummary(data, now) };
}
- async listForRegistrar(registrarId: string, now: Date = new Date()): Promise {
- const { data, error } = await this.db
+ private async readPage(registrarId: string, cursor: LinkCursor | null, pendingAt?: Date): Promise {
+ let query = this.db
.from("teaching_epa_magic_links")
.select(COLUMNS)
.eq("registrar_id", registrarId)
.order("created_at", { ascending: false })
- .limit(LIST_LIMIT);
+ .order("id", { ascending: false })
+ .limit(LIST_LIMIT + 1);
+ if (pendingAt) query = query.eq("status", "pending").gt("expires_at", pendingAt.toISOString());
+ if (cursor)
+ query = query.or(`created_at.lt.${cursor.created_at},and(created_at.eq.${cursor.created_at},id.lt.${cursor.id})`);
+ const { data, error } = await query;
if (error) throw databaseError(error);
- return (data ?? []).map((row) => rowToSummary(row, now));
+ return data ?? [];
+ }
+
+ private async activePending(registrarId: string, now: Date): Promise {
+ // Legacy data can exceed today's ceiling. Page it completely so every waiting link can be cancelled.
+ const result: EpaMagicLinkRow[] = [];
+ let cursor: LinkCursor | null = null;
+ for (;;) {
+ const page = await this.readPage(registrarId, cursor, now);
+ const included = page.slice(0, LIST_LIMIT);
+ result.push(...included);
+ if (page.length <= LIST_LIMIT) return result;
+ const last = included[included.length - 1]!;
+ cursor = { created_at: last.created_at, id: last.id };
+ }
+ }
+
+ async listForRegistrarPage(
+ registrarId: string,
+ now: Date = new Date(),
+ cursorValue: string | null = null,
+ ): Promise {
+ if (!registrarId) throw databaseError(null);
+ const cursor = parseCursor(cursorValue);
+ const [page, pending] = await Promise.all([
+ this.readPage(registrarId, cursor),
+ this.activePending(registrarId, now),
+ ]);
+ const history = page.slice(0, LIST_LIMIT);
+ const rows = new Map([...pending, ...history].map((row) => [row.id, row]));
+ return {
+ links: [...rows.values()].map((row) => rowToSummary(row, now)),
+ nextCursor: page.length > LIST_LIMIT ? cursorFor(history[history.length - 1]!) : null,
+ };
+ }
+
+ /** Compatibility for callers needing the first history page plus every active waiting link. */
+ async listForRegistrar(registrarId: string, now: Date = new Date()): Promise {
+ return [...(await this.listForRegistrarPage(registrarId, now)).links];
}
/** Cancels one of the doctor's own waiting links. False when it is not theirs or no longer waiting. */
diff --git a/src/lib/teaching/assessments/supervisor-bookings.ts b/src/lib/teaching/assessments/supervisor-bookings.ts
index 0832e33159..1d5d27a82f 100644
--- a/src/lib/teaching/assessments/supervisor-bookings.ts
+++ b/src/lib/teaching/assessments/supervisor-bookings.ts
@@ -393,6 +393,11 @@ export class SupervisorBookingRepository {
}
function bookingDatabaseError(error: { message?: string; code?: string } | null): PublicApiError {
+ if (error?.code === "PGRST202" || error?.code === "42883") {
+ return new PublicApiError("Supervision availability is unavailable. Reload before trying again.", 503, {
+ code: "booking_store_unavailable",
+ });
+ }
if (error?.code === "22P02") return new PublicApiError("Invalid booking identifier.", 400, { code: "invalid_id" });
if (error?.code === "23503")
return new PublicApiError("Referenced booking user was not found.", 400, { code: "invalid_reference" });
@@ -422,11 +427,25 @@ function isBookingRow(row: RegistrarAssessmentRow): boolean {
* Durable Supabase-backed booking repository storing supervisor availability and bookings
* in `public.registrar_assessments` under `category = 'supervisor_review'`.
*/
+type BookingOverlapClient = RegistrarAssessmentsTableClient & {
+ rpc(
+ name: "teaching_find_supervisor_overlap_v1",
+ args: {
+ p_supervisor_id: string;
+ p_starts_at: string;
+ p_ends_at: string;
+ p_exclude_id: string;
+ p_before_created_at: string | null;
+ p_before_id: string | null;
+ },
+ ): PromiseLike<{ data: RegistrarAssessmentRow[] | null; error: { message?: string; code?: string } | null }>;
+};
+
export class DurableSupervisorBookingRepository {
- private readonly db: RegistrarAssessmentsTableClient;
+ private readonly db: BookingOverlapClient;
constructor(client: RegistrarAssessmentsTableClient | { from: (...args: never[]) => unknown }) {
- this.db = client as unknown as RegistrarAssessmentsTableClient;
+ this.db = client as unknown as BookingOverlapClient;
}
async getSlot(slotId: string): Promise {
@@ -470,7 +489,9 @@ export class DurableSupervisorBookingRepository {
// The check above and the insert are separate statements, so two concurrent requests can both
// pass it. Re-check after inserting: if an overlapping slot that sorts earlier (created_at, then
// id) exists, this one withdraws itself, so exactly one of any concurrent overlapping pair survives.
- const earlier = (await this.overlappingActiveSlots(created)).filter((other) => compareCreation(other, created) < 0);
+ const earlier = (await this.overlappingActiveSlots(created, true)).filter(
+ (other) => compareCreation(other, created) < 0,
+ );
if (earlier.length > 0) {
if (await this.withdrawConcurrentDuplicate(created)) {
throw new BookingSlotConflictError("Supervisor already has an overlapping availability slot in this window.");
@@ -483,15 +504,21 @@ export class DurableSupervisorBookingRepository {
return created;
}
- /** Every non-cancelled slot of the same supervisor whose window overlaps `slot`, excluding itself. */
- private async overlappingActiveSlots(slot: SupervisorBookingRecord): Promise {
- const supervisorSlots = await this.listBookingsForActor({ actorId: slot.supervisorId, role: "supervisor" });
- return supervisorSlots.filter(
- (existing) =>
- existing.id !== slot.id &&
- existing.bookingState !== "cancelled" &&
- windowsOverlap(existing.startsAt, existing.endsAt, slot.startsAt, slot.endsAt),
- );
+ /** The database compares actual instants and returns at most one relevant overlapping slot. */
+ private async overlappingActiveSlots(
+ slot: SupervisorBookingRecord,
+ earlierOnly = false,
+ ): Promise {
+ const { data, error } = await this.db.rpc("teaching_find_supervisor_overlap_v1", {
+ p_supervisor_id: slot.supervisorId,
+ p_starts_at: slot.startsAt,
+ p_ends_at: slot.endsAt,
+ p_exclude_id: slot.id,
+ p_before_created_at: earlierOnly ? slot.createdAt : null,
+ p_before_id: earlierOnly ? slot.id : null,
+ });
+ if (error) throw bookingDatabaseError(error);
+ return (data ?? []).filter(isBookingRow).map(rowToBookingRecord);
}
/** Withdraws `slot` only while it is still open and unchanged; returns whether it was withdrawn. */
diff --git a/src/lib/work-roles/people.ts b/src/lib/work-roles/people.ts
index 529eb0de57..cfd4e7d33b 100644
--- a/src/lib/work-roles/people.ts
+++ b/src/lib/work-roles/people.ts
@@ -567,10 +567,30 @@ async function archiveHospital(
.eq("id", change.hospitalId)
.is("archived_at", null)
.select("id");
+ if (error?.message?.includes("work_hospital_has_teams")) {
+ throw new PublicApiError("Move its teams first. A hospital with teams can't be archived.", 409, {
+ code: "work_hospital_has_teams",
+ });
+ }
if (error) throw writeFailed(error);
if (!data?.length) throw hospitalUnavailable();
}
+/** A successful write must not be reported as rejected when only the following view read fails. */
+async function readPeopleAfterSavedChange(client: Client, context: WorkRoleContext, hospitalId: string | null) {
+ try {
+ return await readPeople(client, context, hospitalId);
+ } catch {
+ throw new PublicApiError(
+ "The change was saved, but People and roles could not be refreshed. Reload to see its current state.",
+ 503,
+ {
+ code: "work_people_refresh_failed",
+ },
+ );
+ }
+}
+
export async function applyPeopleChange(
client: Client,
context: WorkRoleContext,
@@ -585,16 +605,22 @@ export async function applyPeopleChange(
return readPeople(client, context, await revoke(client, context, change.grantId));
case "link-team": {
if (!isAdministrator(context)) throw denied();
+ const target = check(
+ await client.from("work_hospitals").select("id").eq("id", change.hospitalId).is("archived_at", null).limit(1),
+ );
+ if (!target.length) throw hospitalUnavailable();
+ // A database guard repeats this check while locking the destination against archival.
const { error } = await client
.from("work_hospital_teams")
.insert({ service_id: change.serviceId, hospital_id: change.hospitalId, linked_by: context.userId });
if (error?.code === "23505") {
throw new PublicApiError("That team already belongs to a hospital.", 409, { code: "work_team_linked" });
}
+ if (error?.message?.includes("work_hospital_unavailable")) throw hospitalUnavailable();
if (error?.code === "23503")
throw new PublicApiError("That team or hospital is unavailable.", 404, { code: "work_people_not_found" });
if (error) throw unavailable();
- return readPeople(client, context, change.hospitalId);
+ return readPeopleAfterSavedChange(client, context, change.hospitalId);
}
case "create-hospital": {
if (!isAdministrator(context)) throw denied();
@@ -613,7 +639,7 @@ export async function applyPeopleChange(
return readPeople(client, context, data.id);
}
case "move-team":
- return readPeople(client, context, await moveTeam(client, context, change));
+ return readPeopleAfterSavedChange(client, context, await moveTeam(client, context, change));
case "rename-hospital":
await renameHospital(client, context, change);
return readPeople(client, context, change.hospitalId);
@@ -623,6 +649,6 @@ export async function applyPeopleChange(
case "archive-hospital":
await archiveHospital(client, context, change);
// It drops out of the list, so show the first hospital left.
- return readPeople(client, context, null);
+ return readPeopleAfterSavedChange(client, context, null);
}
}
diff --git a/supabase/applied-migration-hashes.json b/supabase/applied-migration-hashes.json
index 9fe48fe2e4..70618fa72f 100644
--- a/supabase/applied-migration-hashes.json
+++ b/supabase/applied-migration-hashes.json
@@ -1,7 +1,7 @@
{
"note": "sha256 of every migration this repository has shipped, with newlines normalised. An entry whose hash changes means an applied migration was edited, which cannot reach the live database. See scripts/check-migration-immutability.mjs.",
"generator": "scripts/check-migration-immutability.mjs",
- "sealed_at": "2026-10-27T05:58:56.541Z",
+ "sealed_at": "2026-11-05T16:46:23.942Z",
"versions": {
"20260527000000_bulk_ingestion.sql": "813e8e314064a6dcbaaa55e2765a8ce901d5783b304b4a7af7bb7417a07daf7c",
"20260527001000_documents_policy_cleanup.sql": "559c26f3172bf75e545a41adef75538b2bf648ea7d53c8eb3d3ea7c244c65a48",
@@ -351,6 +351,13 @@
"20261026081500_delete_question_logs_with_account.sql": "4d4c0606d2b0595b384b4ea6ae0c3da00b858a5250a03355589541bb415c13d3",
"20261026085000_roster_swap_accept_team_rules.sql": "671be78b7ed16356ac7c0ef24005f558d8ce2b24b678f1f4ba02e0850d1c8aef",
"20261026092000_drop_calendar_link_plain_url.sql": "8fc7ebc22c7221845b8fe4a337c47c81711410914883af369fc58af9c69d1b72",
- "20261027120000_one_supervisor_list.sql": "54465ba4059ff29c1292189971506aa8603896333ed99d5d89c6071a82695bcc"
+ "20261027120000_one_supervisor_list.sql": "54465ba4059ff29c1292189971506aa8603896333ed99d5d89c6071a82695bcc",
+ "20261105144240_audit_private_evidence_lifecycle_and_drift.sql": "77d2f99c6c726d34bae7ca577368cd436ab3a59bcddfd5b69b36c87fde703fcf",
+ "20261105144246_audit_cpd_close_coherence.sql": "e54733b939392249506f4e3629486ae3775a59b4422e0d77bc4ff139395d7906",
+ "20261105144248_audit_hospital_lifecycle.sql": "dbc8646b61261b5bc3b1446f99d5449825ea2db9897da955ecf20d7740d1b223",
+ "20261105144251_audit_atomic_document_maintenance.sql": "cb4cc89b68a8f8c1cb33e5c03bf301c1430094b5ce27fa95aeaf1d03cc951894",
+ "20261105144902_audit_teaching_link_and_booking_lifecycle.sql": "f23642b4aad9b65da68d96784c1fdf43ed71be196f5a9fd76c7b332a8c9d4f79",
+ "20261105150056_audit_cpd_entry_concurrency.sql": "7cef8755b872dd3cb24b4f0d4575c09d8f59c196726ad480288a8774854ee0b2",
+ "20261105163543_audit_retire_unfenced_document_reset.sql": "8ce7681e3352d784d31cbefbf2ca285b725a7590aa47ce4ad84c56390d1213ef"
}
}
diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json
index 73fa6f005a..40ee1d0c0a 100644
--- a/supabase/drift-manifest.json
+++ b/supabase/drift-manifest.json
@@ -1,9 +1,9 @@
{
- "generated_at": "2026-10-26T17:21:29.185Z",
+ "generated_at": "2026-11-05T16:50:45.698Z",
"generator": "scripts/generate-drift-manifest.ts",
"postgres_image": "supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83",
- "schema_sha256": "659066becb0679feef7b71fc356fe57e54e27c5896b9653f827de1464830a319",
- "replay_seconds": 19,
+ "schema_sha256": "86b450b5222023a69463ab44bb03f7383cb04d707dfafa21b83c8878f7386138",
+ "replay_seconds": 5,
"snapshot": {
"views": [
{
@@ -18790,6 +18790,11 @@
"name": "documents_updated_at",
"table": "documents"
},
+ {
+ "def": "CREATE TRIGGER purge_private_ingestion_evidence_after_document_delete AFTER DELETE ON public.documents FOR EACH ROW EXECUTE FUNCTION ingestion_generation_evidence.purge_deleted_document()",
+ "name": "purge_private_ingestion_evidence_after_document_delete",
+ "table": "documents"
+ },
{
"def": "CREATE TRIGGER extra_time_records_updated_at BEFORE UPDATE ON public.extra_time_records FOR EACH ROW EXECUTE FUNCTION public.set_updated_at()",
"name": "extra_time_records_updated_at",
@@ -19025,6 +19030,11 @@
"name": "teaching_epa_magic_links_updated_at",
"table": "teaching_epa_magic_links"
},
+ {
+ "def": "CREATE TRIGGER teaching_epa_pending_limit_guard BEFORE INSERT OR UPDATE OF registrar_id, status, expires_at ON public.teaching_epa_magic_links FOR EACH ROW EXECUTE FUNCTION public.teaching_guard_epa_pending_limit()",
+ "name": "teaching_epa_pending_limit_guard",
+ "table": "teaching_epa_magic_links"
+ },
{
"def": "CREATE TRIGGER teaching_supervision_entry_locked BEFORE UPDATE ON public.teaching_supervision_entries FOR EACH ROW EXECUTE FUNCTION public.teaching_guard_supervision_entry()",
"name": "teaching_supervision_entry_locked",
@@ -19045,6 +19055,11 @@
"name": "work_course_bookings_updated_at",
"table": "work_course_bookings"
},
+ {
+ "def": "CREATE TRIGGER work_hospital_team_live_guard BEFORE INSERT OR UPDATE OF hospital_id ON public.work_hospital_teams FOR EACH ROW EXECUTE FUNCTION public.work_guard_live_hospital_team()",
+ "name": "work_hospital_team_live_guard",
+ "table": "work_hospital_teams"
+ },
{
"def": "CREATE TRIGGER work_team_move_revokes_supervisors AFTER UPDATE OF hospital_id ON public.work_hospital_teams FOR EACH ROW EXECUTE FUNCTION public.work_revoke_supervisors_on_team_move()",
"name": "work_team_move_revokes_supervisors",
@@ -19055,6 +19070,11 @@
"name": "work_team_supervision_sync",
"table": "work_hospital_teams"
},
+ {
+ "def": "CREATE TRIGGER work_hospital_archive_guard BEFORE UPDATE OF archived_at ON public.work_hospitals FOR EACH ROW EXECUTE FUNCTION public.work_guard_hospital_archive()",
+ "name": "work_hospital_archive_guard",
+ "table": "work_hospitals"
+ },
{
"def": "CREATE TRIGGER work_grant_supervision_sync AFTER INSERT OR UPDATE OF revoked_at ON public.work_role_grants FOR EACH ROW EXECUTE FUNCTION public.work_grant_supervision_sync()",
"name": "work_grant_supervision_sync",
@@ -19273,16 +19293,16 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "c1ef5a420ef6a43ea937c6aa80482575",
- "signature": "public.claim_ingestion_jobs(text,integer,integer,uuid[],text[])"
+ "def_hash": "80066ae9391d629393d44dc61b0fc181",
+ "signature": "public.claim_ingestion_jobs(text,integer,integer)"
},
{
"acl": [
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "80066ae9391d629393d44dc61b0fc181",
- "signature": "public.claim_ingestion_jobs(text,integer,integer)"
+ "def_hash": "c1ef5a420ef6a43ea937c6aa80482575",
+ "signature": "public.claim_ingestion_jobs(text,integer,integer,uuid[],text[])"
},
{
"acl": [
@@ -19328,9 +19348,17 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "75e6365d05e5254ed1ab7105bc7a5077",
+ "def_hash": "5441f74e90ac73cd54eacc3c74aeae3b",
"signature": "public.cme_amend_closed_entry(uuid,uuid,jsonb,text)"
},
+ {
+ "acl": [
+ "postgres=X/postgres",
+ "service_role=X/postgres"
+ ],
+ "def_hash": "f92eb7f19e851854565ce471c3e5da6c",
+ "signature": "public.cme_amend_closed_entry_v2(uuid,uuid,jsonb,text)"
+ },
{
"acl": [
"postgres=X/postgres",
@@ -19352,9 +19380,17 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "bc871df2f59aba77fb9a0979da2f9d86",
+ "def_hash": "66fb1271bb3f11bb7692292bde3f2a00",
"signature": "public.cme_close_year(uuid,uuid,jsonb,text)"
},
+ {
+ "acl": [
+ "postgres=X/postgres",
+ "service_role=X/postgres"
+ ],
+ "def_hash": "d44a8f5f7ec24127a53868d05209eaca",
+ "signature": "public.cme_close_year_v2(uuid,uuid,jsonb,text)"
+ },
{
"acl": [
"postgres=X/postgres",
@@ -19371,6 +19407,14 @@
"def_hash": "b2f5382c9150563f8dce318266177404",
"signature": "public.cme_entry_amendment_in_progress(uuid,uuid)"
},
+ {
+ "acl": [
+ "postgres=X/postgres",
+ "service_role=X/postgres"
+ ],
+ "def_hash": "bdf72d405484a5ba6404e5dfd47f1127",
+ "signature": "public.cme_entry_edit_version(uuid,uuid)"
+ },
{
"acl": [
"postgres=X/postgres",
@@ -19391,7 +19435,7 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "2b65f251c7ffbb1865207e0ad2c6d314",
+ "def_hash": "f70d8c2c56e7a990ac4598054b86857b",
"signature": "public.cme_guard_allocation()"
},
{
@@ -19415,16 +19459,16 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "e6ea7f5c3e88eda5ab682f24fddf50b3",
- "signature": "public.cme_guard_entry_goal()"
+ "def_hash": "442a83ee92ad1b9f41e8b3cb01bc494f",
+ "signature": "public.cme_guard_entry()"
},
{
"acl": [
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "442a83ee92ad1b9f41e8b3cb01bc494f",
- "signature": "public.cme_guard_entry()"
+ "def_hash": "e6ea7f5c3e88eda5ab682f24fddf50b3",
+ "signature": "public.cme_guard_entry_goal()"
},
{
"acl": [
@@ -19447,7 +19491,7 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "997a20a5a425edd6bb7c3c25e0e8eff2",
+ "def_hash": "9dc21188c933cf36fc4fdbdda16185e2",
"signature": "public.cme_guard_requirement()"
},
{
@@ -19458,6 +19502,14 @@
"def_hash": "abe51d02647e951d5a78696813c18216",
"signature": "public.cme_guard_year()"
},
+ {
+ "acl": [
+ "postgres=X/postgres",
+ "service_role=X/postgres"
+ ],
+ "def_hash": "d49e3fb5c0812ebc682ae49dce4ff426",
+ "signature": "public.cme_remove_evidence(uuid,uuid,text)"
+ },
{
"acl": [
"postgres=X/postgres",
@@ -19471,16 +19523,16 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "d49e3fb5c0812ebc682ae49dce4ff426",
- "signature": "public.cme_remove_evidence(uuid,uuid,text)"
+ "def_hash": "a8ff4e9a90a7d404be21f218d29ea6ee",
+ "signature": "public.cme_save_entry(uuid,uuid,uuid,jsonb,boolean,uuid)"
},
{
"acl": [
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "cb723dadd2d13699cf4443a140368edb",
- "signature": "public.cme_save_entry(uuid,uuid,uuid,jsonb,boolean,uuid)"
+ "def_hash": "e276a589f81e815c7e2c2bb5aa2945c6",
+ "signature": "public.cme_save_entry_v2(uuid,uuid,uuid,jsonb,boolean,uuid)"
},
{
"acl": [
@@ -19532,8 +19584,7 @@
},
{
"acl": [
- "postgres=X/postgres",
- "service_role=X/postgres"
+ "postgres=X/postgres"
],
"def_hash": "927067ef3e76b8c072711845574a95b9",
"signature": "public.commit_document_deep_memory_generation(uuid,text,uuid,text,text,integer,integer,jsonb,integer)"
@@ -19560,6 +19611,14 @@
"def_hash": "594b1f715fbbfa1df1b1f1183a7fef5a",
"signature": "public.commit_document_index_generation(uuid,uuid,text,integer,integer,integer,jsonb,jsonb,jsonb)"
},
+ {
+ "acl": [
+ "postgres=X/postgres",
+ "service_role=X/postgres"
+ ],
+ "def_hash": "0f36fae87ec259f47ec2223d24ae1e55",
+ "signature": "public.commit_document_maintenance_deep_memory(uuid,uuid,timestamp with time zone,uuid,jsonb,jsonb,text,uuid,text,text,integer,integer,jsonb,integer)"
+ },
{
"acl": [
"postgres=X/postgres",
@@ -19643,16 +19702,16 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "f909017309f1d81e24c03b419ff8d46b",
- "signature": "public.corpus_topic_term_stats_v2(text[],uuid,boolean)"
+ "def_hash": "b156648e44edd76cf7c0d593c225880d",
+ "signature": "public.corpus_topic_term_stats(text[],uuid)"
},
{
"acl": [
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "b156648e44edd76cf7c0d593c225880d",
- "signature": "public.corpus_topic_term_stats(text[],uuid)"
+ "def_hash": "f909017309f1d81e24c03b419ff8d46b",
+ "signature": "public.corpus_topic_term_stats_v2(text[],uuid,boolean)"
},
{
"acl": [
@@ -19690,7 +19749,7 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "cf81c12782a5f977f4cddee4bd918b84",
+ "def_hash": "217ddfda304309af01e8a8a0dd6d9fd6",
"signature": "public.delete_document_if_idle(uuid,uuid,text,text)"
},
{
@@ -19747,6 +19806,14 @@
"def_hash": "2314085d7e21c5d54f3997e00fd8cdd9",
"signature": "public.enforce_document_title_word_scope()"
},
+ {
+ "acl": [
+ "postgres=X/postgres",
+ "service_role=X/postgres"
+ ],
+ "def_hash": "7fe8a0c8be8d30accc3774e639debe9f",
+ "signature": "public.enqueue_import_document(uuid,uuid,text,uuid,boolean,integer)"
+ },
{
"acl": [
"postgres=X/postgres",
@@ -19807,16 +19874,16 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "8bc8a230f147790d5ffb77345627d4da",
- "signature": "public.get_related_document_metadata_v2(uuid[],uuid,boolean)"
+ "def_hash": "497a62a59a14726895c8ba1177515bec",
+ "signature": "public.get_related_document_metadata(uuid[],uuid)"
},
{
"acl": [
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "497a62a59a14726895c8ba1177515bec",
- "signature": "public.get_related_document_metadata(uuid[],uuid)"
+ "def_hash": "8bc8a230f147790d5ffb77345627d4da",
+ "signature": "public.get_related_document_metadata_v2(uuid[],uuid,boolean)"
},
{
"acl": [
@@ -20015,24 +20082,32 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "d66bcae6cf1a0d3cab904d61abf85af2",
- "signature": "public.match_document_chunks_hybrid_v2(extensions.vector,text,integer,double precision,uuid[],uuid,boolean)"
+ "def_hash": "cdf9d685c98bc8ff731a0422c29a47a4",
+ "signature": "public.match_document_chunks(extensions.vector,integer,double precision,uuid,uuid)"
},
{
"acl": [
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "3d14fc49b6e89da5107d52e88b045cd7",
- "signature": "public.match_document_chunks_hybrid_v3(extensions.vector,text,integer,double precision,uuid[],uuid,boolean,text[],uuid,text,bigint,text[])"
+ "def_hash": "5902c39286335c07714e498ea31513a0",
+ "signature": "public.match_document_chunks_hybrid(extensions.vector,text,integer,double precision,uuid[],uuid)"
},
{
"acl": [
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "5902c39286335c07714e498ea31513a0",
- "signature": "public.match_document_chunks_hybrid(extensions.vector,text,integer,double precision,uuid[],uuid)"
+ "def_hash": "d66bcae6cf1a0d3cab904d61abf85af2",
+ "signature": "public.match_document_chunks_hybrid_v2(extensions.vector,text,integer,double precision,uuid[],uuid,boolean)"
+ },
+ {
+ "acl": [
+ "postgres=X/postgres",
+ "service_role=X/postgres"
+ ],
+ "def_hash": "3d14fc49b6e89da5107d52e88b045cd7",
+ "signature": "public.match_document_chunks_hybrid_v3(extensions.vector,text,integer,double precision,uuid[],uuid,boolean,text[],uuid,text,bigint,text[])"
},
{
"acl": [
@@ -20047,24 +20122,24 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "3639b2442bac7b2e7b18ad322a395acb",
- "signature": "public.match_document_chunks_text_v2(text,integer,uuid[],uuid,boolean)"
+ "def_hash": "d135c628720cb8a4d86c2ade4cd3b26a",
+ "signature": "public.match_document_chunks_text(text,integer,uuid[],uuid)"
},
{
"acl": [
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "062f41aa0378d6582690cba86550028d",
- "signature": "public.match_document_chunks_text_v3(text,integer,uuid[],uuid,boolean,text[],uuid,text,bigint,text[])"
+ "def_hash": "3639b2442bac7b2e7b18ad322a395acb",
+ "signature": "public.match_document_chunks_text_v2(text,integer,uuid[],uuid,boolean)"
},
{
"acl": [
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "d135c628720cb8a4d86c2ade4cd3b26a",
- "signature": "public.match_document_chunks_text(text,integer,uuid[],uuid)"
+ "def_hash": "062f41aa0378d6582690cba86550028d",
+ "signature": "public.match_document_chunks_text_v3(text,integer,uuid[],uuid,boolean,text[],uuid,text,bigint,text[])"
},
{
"acl": [
@@ -20087,8 +20162,8 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "cdf9d685c98bc8ff731a0422c29a47a4",
- "signature": "public.match_document_chunks(extensions.vector,integer,double precision,uuid,uuid)"
+ "def_hash": "bb975485ee3a5776bce4abdc2e3a3cbd",
+ "signature": "public.match_document_embedding_fields_hybrid(extensions.vector,text,integer,double precision,uuid[],uuid)"
},
{
"acl": [
@@ -20103,16 +20178,16 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "bb975485ee3a5776bce4abdc2e3a3cbd",
- "signature": "public.match_document_embedding_fields_hybrid(extensions.vector,text,integer,double precision,uuid[],uuid)"
+ "def_hash": "48117cf83cf9221a3630e31af0c701ec",
+ "signature": "public.match_document_embedding_fields_text(text,integer,double precision,uuid[],uuid)"
},
{
"acl": [
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "48117cf83cf9221a3630e31af0c701ec",
- "signature": "public.match_document_embedding_fields_text(text,integer,double precision,uuid[],uuid)"
+ "def_hash": "d0e277a2f3067f49463b85ac84b33276",
+ "signature": "public.match_document_index_units_hybrid(extensions.vector,text,integer,double precision,uuid[],uuid)"
},
{
"acl": [
@@ -20135,8 +20210,8 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "d0e277a2f3067f49463b85ac84b33276",
- "signature": "public.match_document_index_units_hybrid(extensions.vector,text,integer,double precision,uuid[],uuid)"
+ "def_hash": "c1ede773fc0498e32bc3b4aa7262b32c",
+ "signature": "public.match_document_lookup_chunks_text(text,uuid[],integer,uuid)"
},
{
"acl": [
@@ -20151,8 +20226,8 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "c1ede773fc0498e32bc3b4aa7262b32c",
- "signature": "public.match_document_lookup_chunks_text(text,uuid[],integer,uuid)"
+ "def_hash": "9079b928ee56fd7846e280d10ba1d27c",
+ "signature": "public.match_document_memory_cards_hybrid(extensions.vector,text,integer,double precision,uuid[],uuid)"
},
{
"acl": [
@@ -20175,16 +20250,16 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "9079b928ee56fd7846e280d10ba1d27c",
- "signature": "public.match_document_memory_cards_hybrid(extensions.vector,text,integer,double precision,uuid[],uuid)"
+ "def_hash": "8248e8d1aec23bbad35aca0ddc4dc94d",
+ "signature": "public.match_documents_for_query(text,integer,uuid)"
},
{
"acl": [
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "e771e8530913609c5179b5761764b003",
- "signature": "public.match_document_table_facts_text_v2(text,integer,uuid[],uuid,boolean)"
+ "def_hash": "727b925c6663e000a8776111060615a1",
+ "signature": "public.match_documents_for_query_v2(text,integer,uuid,boolean)"
},
{
"acl": [
@@ -20199,16 +20274,8 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "727b925c6663e000a8776111060615a1",
- "signature": "public.match_documents_for_query_v2(text,integer,uuid,boolean)"
- },
- {
- "acl": [
- "postgres=X/postgres",
- "service_role=X/postgres"
- ],
- "def_hash": "8248e8d1aec23bbad35aca0ddc4dc94d",
- "signature": "public.match_documents_for_query(text,integer,uuid)"
+ "def_hash": "e771e8530913609c5179b5761764b003",
+ "signature": "public.match_document_table_facts_text_v2(text,integer,uuid[],uuid,boolean)"
},
{
"acl": [
@@ -20565,16 +20632,16 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "d9875e7dbcdf19b56945c9d8de05d7c2",
- "signature": "public.record_source_review_v2(uuid,uuid,text,text,text[],date,uuid,text,text)"
+ "def_hash": "874163cf5aa0b818f3e88ca1ac6f90b2",
+ "signature": "public.record_source_review(uuid,uuid,text,text,text[],date,uuid)"
},
{
"acl": [
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "874163cf5aa0b818f3e88ca1ac6f90b2",
- "signature": "public.record_source_review(uuid,uuid,text,text,text[],date,uuid)"
+ "def_hash": "d9875e7dbcdf19b56945c9d8de05d7c2",
+ "signature": "public.record_source_review_v2(uuid,uuid,text,text,text[],date,uuid,text,text)"
},
{
"acl": [
@@ -20639,6 +20706,14 @@
"def_hash": "31258714cad37bf42e122f60c88e9242",
"signature": "public.repair_strict_enrichment_gate_batch(integer)"
},
+ {
+ "acl": [
+ "postgres=X/postgres",
+ "service_role=X/postgres"
+ ],
+ "def_hash": "afd86d782e282761856dc209c781cd90",
+ "signature": "public.replace_document_maintenance_artifacts(uuid,uuid,timestamp with time zone,uuid,text,jsonb)"
+ },
{
"acl": [
"postgres=X/postgres",
@@ -20710,14 +20785,6 @@
"def_hash": "f4e907986b31bb6b80e77c3a502d0afe",
"signature": "public.reserve_public_source_version(jsonb)"
},
- {
- "acl": [
- "postgres=X/postgres",
- "service_role=X/postgres"
- ],
- "def_hash": "243f3960a32db0192d1cce2ebd050004",
- "signature": "public.reset_document_index(uuid)"
- },
{
"acl": [
"postgres=X/postgres",
@@ -20771,16 +20838,16 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "1bc5c6e76cdaa83b6831b36413fb61f5",
- "signature": "public.retrieval_owner_matches_v2(uuid,uuid,boolean)"
+ "def_hash": "ca2877374851b432c4ca971e1e15f746",
+ "signature": "public.retrieval_owner_matches(uuid,uuid)"
},
{
"acl": [
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "ca2877374851b432c4ca971e1e15f746",
- "signature": "public.retrieval_owner_matches(uuid,uuid)"
+ "def_hash": "1bc5c6e76cdaa83b6831b36413fb61f5",
+ "signature": "public.retrieval_owner_matches_v2(uuid,uuid,boolean)"
},
{
"acl": [
@@ -20822,14 +20889,6 @@
"def_hash": "4ba043a0458fc8055bddce8ee7a01860",
"signature": "public.roster_calendar_link_add(uuid,text,text)"
},
- {
- "acl": [
- "postgres=X/postgres",
- "service_role=X/postgres"
- ],
- "def_hash": "00a0abe0f57c7fcf3fd1f730ab730fd0",
- "signature": "public.roster_calendar_link_urls(uuid,uuid[])"
- },
{
"acl": [
"postgres=X/postgres"
@@ -20851,6 +20910,14 @@
"def_hash": "225129d94188f7a622fead0e0e4a7aad",
"signature": "public.roster_calendar_links_pin_secret()"
},
+ {
+ "acl": [
+ "postgres=X/postgres",
+ "service_role=X/postgres"
+ ],
+ "def_hash": "00a0abe0f57c7fcf3fd1f730ab730fd0",
+ "signature": "public.roster_calendar_link_urls(uuid,uuid[])"
+ },
{
"acl": [
"postgres=X/postgres",
@@ -21101,7 +21168,7 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "d4604873b639ee7f2592c1013f9c5563",
+ "def_hash": "d8a2df2d9e40953ae14a5bf741e77cc7",
"signature": "public.schema_drift_snapshot()"
},
{
@@ -21480,6 +21547,14 @@
"def_hash": "97c7d7ac314eb2ce3a268c67718b7235",
"signature": "public.teaching_const_eq(text,text)"
},
+ {
+ "acl": [
+ "postgres=X/postgres",
+ "service_role=X/postgres"
+ ],
+ "def_hash": "c609ebebdc33b06378e96238892a1e2a",
+ "signature": "public.teaching_create_epa_magic_link_v1(uuid,jsonb)"
+ },
{
"acl": [
"postgres=X/postgres",
@@ -21520,6 +21595,22 @@
"def_hash": "2c394b4d9809e679a339230af729a710",
"signature": "public.teaching_feed_events(uuid,date,date)"
},
+ {
+ "acl": [
+ "postgres=X/postgres",
+ "service_role=X/postgres"
+ ],
+ "def_hash": "3e487ca919bc6c80dbec9a6022045b77",
+ "signature": "public.teaching_find_supervisor_overlap_v1(uuid,timestamp with time zone,timestamp with time zone,uuid,timestamp with time zone,uuid)"
+ },
+ {
+ "acl": [
+ "postgres=X/postgres",
+ "service_role=X/postgres"
+ ],
+ "def_hash": "ab5294b4d530b41d5c1eef89099aa131",
+ "signature": "public.teaching_guard_epa_pending_limit()"
+ },
{
"acl": [
"postgres=X/postgres",
@@ -21842,24 +21933,24 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "6221817df14048d758d9e6765f226916",
- "signature": "public.work_can(uuid,text,uuid,uuid,uuid)"
+ "def_hash": "0c76ee8c1181dbac0c7d078352256785",
+ "signature": "public.work_cancel_course_booking(uuid,uuid)"
},
{
"acl": [
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "0c76ee8c1181dbac0c7d078352256785",
- "signature": "public.work_cancel_course_booking(uuid,uuid)"
+ "def_hash": "648b41850477258c0d015136b043bb98",
+ "signature": "public.work_cancel_course(uuid,uuid)"
},
{
"acl": [
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "648b41850477258c0d015136b043bb98",
- "signature": "public.work_cancel_course(uuid,uuid)"
+ "def_hash": "6221817df14048d758d9e6765f226916",
+ "signature": "public.work_can(uuid,text,uuid,uuid,uuid)"
},
{
"acl": [
@@ -21869,6 +21960,22 @@
"def_hash": "24cd1765a6cfade89f9d7f0adbebc869",
"signature": "public.work_grant_supervision_sync()"
},
+ {
+ "acl": [
+ "postgres=X/postgres",
+ "service_role=X/postgres"
+ ],
+ "def_hash": "26ae27173cf2d18127fa602934df3eff",
+ "signature": "public.work_guard_hospital_archive()"
+ },
+ {
+ "acl": [
+ "postgres=X/postgres",
+ "service_role=X/postgres"
+ ],
+ "def_hash": "015aa25282ca7006ccf1d331afe75224",
+ "signature": "public.work_guard_live_hospital_team()"
+ },
{
"acl": [
"postgres=X/postgres",
@@ -21971,10 +22078,6 @@
"name": "pg_cron",
"schema": "pg_catalog"
},
- {
- "name": "pg_stat_statements",
- "schema": "extensions"
- },
{
"name": "pg_trgm",
"schema": "extensions"
@@ -27003,6 +27106,3552 @@
"table": "work_role_grants"
}
],
+ "private_objects": [
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ]
+ },
+ "identity": "default_acl:postgres:*:f"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "4cf07554905c75081100dd4d49bc85c4",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.admin_pdf_blob(jsonb)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "67c7430d30fefe57bde4158c379a50c4",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.admin_pdf_commit_allowed(jsonb,uuid)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "d0fe57e7c9fba393590459d8c6a5c6bb",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.admin_pdf_evaluate(ingestion_generation_evidence.candidates,jsonb,jsonb)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "8d2adfa0429f72567f3496d9fb0be517",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.admin_pdf_fixed_evidence(ingestion_generation_evidence.candidates,jsonb)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "708b9441208963ff1dcda94d586f6ef4",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.admin_pdf_ref(bytea)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "d2f4a60616adb826943329753b0c7f52",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.admin_reference_context(uuid,uuid,text,jsonb)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "ba495364cdae83d01ad629852f8d2161",
+ "security_definer": true
+ },
+ "identity": "function:ingestion_generation_evidence.admin_reference_core(uuid,uuid,uuid[],text,jsonb)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "c2e27eca0fa3e74603a7360756303b6e",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.admin_reference_decode(text[])"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "f41bbdab0531d0250d73f0e9bb3f08b8",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.admin_reference_digest(jsonb)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "171e3902fabfd1238830bf22e2def63b",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.admin_reference_evidence(jsonb)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "943239a3ef739554cb8c3dd441f00a01",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.admin_reference_receipt(jsonb,jsonb)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "70c4121e77df3d27fa0489071ebe8a59",
+ "security_definer": true
+ },
+ "identity": "function:ingestion_generation_evidence.admin_reference_reviewed_state(uuid,jsonb)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "4d40adfc21cf109255a880f04009dbdf",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.admin_text_commit_allowed(jsonb,uuid)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "861ed5afa7109c7d449f385654b89d83",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.admin_text_quality(ingestion_generation_evidence.candidates,integer)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "03d7799f0986f1c0c57971b2769e5558",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.admin_text_utf16_length(text)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "cadd91b6bb07312749c1fec1674aa611",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.assert_admin_reference_sections(ingestion_generation_evidence.candidates,jsonb,boolean,uuid)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "e20a35e2ab56a92f99a57fc4f4268bd1",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.assert_admin_text_quality_state(ingestion_generation_evidence.candidates,jsonb,text)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "d6855dd423f0c25850095fdb6632a6f0",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.assert_admin_text_sections(ingestion_generation_evidence.candidates,jsonb,boolean)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "366ce7915dc4176c28efa22388c83131",
+ "security_definer": true
+ },
+ "identity": "function:ingestion_generation_evidence.assert_admin_text_source_section_layout(ingestion_generation_evidence.candidates)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "lock_timeout=10s",
+ "search_path=\"\""
+ ],
+ "def_hash": "39db1416a00e95af142f6bd024085ede",
+ "security_definer": true
+ },
+ "identity": "function:ingestion_generation_evidence.assert_deep_memory_staging_owner(jsonb,jsonb,uuid)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "b115e8ef202119cd0a8a9884845bcc62",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.candidate_enrichment_inputs(ingestion_generation_evidence.candidates,jsonb)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "268b0041b9e5f3b6442370a0165a44f4",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.checked_native_parent_closure(ingestion_generation_evidence.candidates,text)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "245ea7e7e493137dfdaf11f8259499fb",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.complete_admin_pdf(jsonb,uuid,jsonb,boolean)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "700a121370ab6e92a3f09957ddf768c4",
+ "security_definer": true
+ },
+ "identity": "function:ingestion_generation_evidence.declare_groups(ingestion_generation_evidence.candidates,jsonb)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "df0499de6586b6071910a97a32837c9d",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.guard_page_mode()"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "422a70810deda2d9925dbe6b7fa3e7a1",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.immutable()"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "fc2959ed8f714b12623b9685f118ab08",
+ "security_definer": true
+ },
+ "identity": "function:ingestion_generation_evidence.inventory(ingestion_generation_evidence.candidates)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "feee625e2b23dbbb5922c7a9f1599145",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.keys(jsonb,text[])"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "e77e9d65ad8910e6a4226799f1cdeed1",
+ "security_definer": true
+ },
+ "identity": "function:ingestion_generation_evidence.lock_candidate(jsonb,uuid)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "f2e1bf60651b0f3da4011268278c3b1b",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.managed_vector_artifact(ingestion_generation_evidence.candidates,jsonb)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "7ca625fb19f15bcafa1639b19fadf71b",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.native_parent_closure_bundle(jsonb)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "a9208a88c4baf55301229c27964a59de",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.persist_candidate_chunks(jsonb,uuid,jsonb)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "c5db794a03818cd5c06dbd11fb8fc071",
+ "security_definer": true
+ },
+ "identity": "function:ingestion_generation_evidence.purge_deleted_document()"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "82b0fc6daac469fc9de453a5fc553fc5",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.read_native_parent_closure(jsonb,uuid,text)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "f1b42dfb4cd49d70b8f095226b04142c",
+ "security_definer": true
+ },
+ "identity": "function:ingestion_generation_evidence.retain(ingestion_generation_evidence.candidates,jsonb)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "d3a7004f86e454f7b4f5e51ecfbe0275",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.schema_drift_inventory()"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "e41f85022153b416a15d15331c38be71",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.slots()"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "ecc05545862ffed3437d4584f834b663",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.stage_native_parent_closure(jsonb,uuid,jsonb)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "f32a7986f19e3d64329bd7a86c3b9a1b",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.validate_native_parent_closure(ingestion_generation_evidence.candidates,jsonb)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "owner": "postgres",
+ "config": [
+ "search_path=\"\""
+ ],
+ "def_hash": "97ef23dda00c015f21f6645db7c2ed8d",
+ "security_definer": false
+ },
+ "identity": "function:ingestion_generation_evidence.visual_unit_fence(jsonb)"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=arwdDxtm/postgres"
+ ],
+ "kind": "r",
+ "owner": "postgres",
+ "columns": [
+ {
+ "acl": null,
+ "name": "anchor_id",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "candidate_authority_digest",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "candidate_job_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "candidate_lease_token",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "candidate_owner_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "candidate_prepared_sha256",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "candidate_source_expectation_digest",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "candidate_source_sha256",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "chunk_index",
+ "type": "integer",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "content",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "content_hash",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "default": "now()",
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "document_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "embedding",
+ "type": "extensions.vector(1536)",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "heading_level",
+ "type": "integer",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "id",
+ "type": "uuid",
+ "default": "gen_random_uuid()",
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "image_ids",
+ "type": "uuid[]",
+ "default": "'{}'::uuid[]",
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "index_generation_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "metadata",
+ "type": "jsonb",
+ "default": "'{}'::jsonb",
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "page_number",
+ "type": "integer",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "parent_heading",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "retrieval_synopsis",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "search_tsv",
+ "type": "tsvector",
+ "default": "to_tsvector('english'::regconfig, ((((COALESCE(section_heading, ''::text) || ' '::text) || COALESCE(retrieval_synopsis, ''::text)) || ' '::text) || content))",
+ "identity": "",
+ "not_null": false,
+ "generated": "s"
+ },
+ {
+ "acl": null,
+ "name": "section_heading",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "section_path",
+ "type": "text[]",
+ "default": "'{}'::text[]",
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "token_estimate",
+ "type": "integer",
+ "default": "0",
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ }
+ ],
+ "indexes": [
+ {
+ "live": true,
+ "ready": true,
+ "valid": true,
+ "identity": "ingestion_generation_evidence.candidate_chunk_rows_document_id_index_generation_id_chunk__key",
+ "definition": "CREATE UNIQUE INDEX candidate_chunk_rows_document_id_index_generation_id_chunk__key ON ingestion_generation_evidence.candidate_chunk_rows USING btree (document_id, index_generation_id, chunk_index)"
+ },
+ {
+ "live": true,
+ "ready": true,
+ "valid": true,
+ "identity": "ingestion_generation_evidence.candidate_chunk_rows_pkey",
+ "definition": "CREATE UNIQUE INDEX candidate_chunk_rows_pkey ON ingestion_generation_evidence.candidate_chunk_rows USING btree (id)"
+ }
+ ],
+ "policies": null,
+ "sequence": null,
+ "triggers": [
+ {
+ "name": "evidence_immutable",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_immutable BEFORE DELETE OR UPDATE ON ingestion_generation_evidence.candidate_chunk_rows FOR EACH ROW EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ },
+ {
+ "name": "evidence_no_truncate",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_no_truncate BEFORE TRUNCATE ON ingestion_generation_evidence.candidate_chunk_rows FOR EACH STATEMENT EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ }
+ ],
+ "reloptions": null,
+ "rls_forced": false,
+ "constraints": [
+ {
+ "name": "candidate_chunk_rows_candidate_authority_digest_check",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((candidate_authority_digest ~ '^sha256:[0-9a-f]{64}$'::text))"
+ },
+ {
+ "name": "candidate_chunk_rows_candidate_prepared_sha256_check",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((candidate_prepared_sha256 ~ '^sha256:[0-9a-f]{64}$'::text))"
+ },
+ {
+ "name": "candidate_chunk_rows_candidate_source_expectation_digest_check",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((candidate_source_expectation_digest ~ '^sha256:[0-9a-f]{64}$'::text))"
+ },
+ {
+ "name": "candidate_chunk_rows_candidate_source_sha256_check",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((candidate_source_sha256 ~ '^sha256:[0-9a-f]{64}$'::text))"
+ },
+ {
+ "name": "candidate_chunk_rows_document_id_index_generation_id_chunk__key",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "UNIQUE (document_id, index_generation_id, chunk_index)"
+ },
+ {
+ "name": "candidate_chunk_rows_document_id_index_generation_id_fkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "FOREIGN KEY (document_id, index_generation_id) REFERENCES ingestion_generation_evidence.candidates(document_id, generation_id)"
+ },
+ {
+ "name": "candidate_chunk_rows_pkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "PRIMARY KEY (id)"
+ },
+ {
+ "name": "document_chunks_content_not_blank",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((length(btrim(content)) > 0))"
+ }
+ ],
+ "rls_enabled": true,
+ "view_definition": null
+ },
+ "identity": "relation:ingestion_generation_evidence.candidate_chunk_rows"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=arwdDxtm/postgres"
+ ],
+ "kind": "r",
+ "owner": "postgres",
+ "columns": [
+ {
+ "acl": null,
+ "name": "authority_digest",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "default": "clock_timestamp()",
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "document_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "expected_inventory_digest",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "first_lease_token",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "generation_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "job_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "predecessor_generation_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "profile_version",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "registration",
+ "type": "jsonb",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "source_byte_length",
+ "type": "bigint",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "source_expectation",
+ "type": "jsonb",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "source_expectation_bytes",
+ "type": "bytea",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "source_expectation_digest",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "source_section_layout",
+ "type": "jsonb",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "source_section_layout_bytes",
+ "type": "bytea",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "source_section_layout_digest",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "source_sha256",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "source_snapshot",
+ "type": "jsonb",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ }
+ ],
+ "indexes": [
+ {
+ "live": true,
+ "ready": true,
+ "valid": true,
+ "identity": "ingestion_generation_evidence.candidates_pkey",
+ "definition": "CREATE UNIQUE INDEX candidates_pkey ON ingestion_generation_evidence.candidates USING btree (document_id, generation_id)"
+ }
+ ],
+ "policies": null,
+ "sequence": null,
+ "triggers": [
+ {
+ "name": "evidence_immutable",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_immutable BEFORE DELETE OR UPDATE ON ingestion_generation_evidence.candidates FOR EACH ROW EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ },
+ {
+ "name": "evidence_no_truncate",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_no_truncate BEFORE TRUNCATE ON ingestion_generation_evidence.candidates FOR EACH STATEMENT EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ }
+ ],
+ "reloptions": null,
+ "rls_forced": false,
+ "constraints": [
+ {
+ "name": "admin_text_source_section_layout_complete",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((((source_section_layout IS NULL) AND (source_section_layout_bytes IS NULL) AND (source_section_layout_digest IS NULL)) OR ((source_section_layout IS NOT NULL) AND (source_section_layout_bytes IS NOT NULL) AND (source_section_layout_digest IS NOT NULL) AND (source_section_layout_digest ~ '^sha256:[0-9a-f]{64}$'::text) AND (source_section_layout_digest = ('sha256:'::text || encode(extensions.digest(source_section_layout_bytes, 'sha256'::text), 'hex'::text))))))"
+ },
+ {
+ "name": "candidates_pkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "PRIMARY KEY (document_id, generation_id)"
+ },
+ {
+ "name": "candidates_profile_version_check",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((profile_version = 'u10-u12-prerequisite-profile-v1'::text))"
+ },
+ {
+ "name": "candidates_source_byte_length_check",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK (((source_byte_length >= 1) AND (source_byte_length <= 157286400)))"
+ },
+ {
+ "name": "candidates_source_sha256_check",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((source_sha256 ~ '^sha256:[0-9a-f]{64}$'::text))"
+ }
+ ],
+ "rls_enabled": true,
+ "view_definition": null
+ },
+ "identity": "relation:ingestion_generation_evidence.candidates"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=arwdDxtm/postgres"
+ ],
+ "kind": "r",
+ "owner": "postgres",
+ "columns": [
+ {
+ "acl": null,
+ "name": "anchor_id",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "chunk_index",
+ "type": "integer",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "content",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "content_hash",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "default": "now()",
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "document_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "embedding",
+ "type": "extensions.vector(1536)",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "heading_level",
+ "type": "integer",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "id",
+ "type": "uuid",
+ "default": "gen_random_uuid()",
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "image_ids",
+ "type": "uuid[]",
+ "default": "'{}'::uuid[]",
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "index_generation_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "metadata",
+ "type": "jsonb",
+ "default": "'{}'::jsonb",
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "packet_identity",
+ "type": "jsonb",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "page_number",
+ "type": "integer",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "parent_heading",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "prepared_sha256",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "retrieval_synopsis",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "search_tsv",
+ "type": "tsvector",
+ "default": "to_tsvector('english'::regconfig, ((((COALESCE(section_heading, ''::text) || ' '::text) || COALESCE(retrieval_synopsis, ''::text)) || ' '::text) || content))",
+ "identity": "",
+ "not_null": false,
+ "generated": "s"
+ },
+ {
+ "acl": null,
+ "name": "section_heading",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "section_path",
+ "type": "text[]",
+ "default": "'{}'::text[]",
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "token_estimate",
+ "type": "integer",
+ "default": "0",
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ }
+ ],
+ "indexes": [
+ {
+ "live": true,
+ "ready": true,
+ "valid": true,
+ "identity": "ingestion_generation_evidence.managed_vector_chunk_rows_document_id_index_generation_id_c_key",
+ "definition": "CREATE UNIQUE INDEX managed_vector_chunk_rows_document_id_index_generation_id_c_key ON ingestion_generation_evidence.managed_vector_chunk_rows USING btree (document_id, index_generation_id, chunk_index)"
+ },
+ {
+ "live": true,
+ "ready": true,
+ "valid": true,
+ "identity": "ingestion_generation_evidence.managed_vector_chunk_rows_pkey",
+ "definition": "CREATE UNIQUE INDEX managed_vector_chunk_rows_pkey ON ingestion_generation_evidence.managed_vector_chunk_rows USING btree (id)"
+ }
+ ],
+ "policies": null,
+ "sequence": null,
+ "triggers": [
+ {
+ "name": "evidence_immutable",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_immutable BEFORE DELETE OR UPDATE ON ingestion_generation_evidence.managed_vector_chunk_rows FOR EACH ROW EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ },
+ {
+ "name": "evidence_no_truncate",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_no_truncate BEFORE TRUNCATE ON ingestion_generation_evidence.managed_vector_chunk_rows FOR EACH STATEMENT EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ }
+ ],
+ "reloptions": null,
+ "rls_forced": false,
+ "constraints": [
+ {
+ "name": "document_chunks_content_not_blank",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((length(btrim(content)) > 0))"
+ },
+ {
+ "name": "managed_vector_chunk_rows_document_id_index_generation_id_c_key",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "UNIQUE (document_id, index_generation_id, chunk_index)"
+ },
+ {
+ "name": "managed_vector_chunk_rows_document_id_index_generation_id_fkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "FOREIGN KEY (document_id, index_generation_id) REFERENCES ingestion_generation_evidence.managed_vector_packets(document_id, generation_id)"
+ },
+ {
+ "name": "managed_vector_chunk_rows_pkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "PRIMARY KEY (id)"
+ }
+ ],
+ "rls_enabled": true,
+ "view_definition": null
+ },
+ "identity": "relation:ingestion_generation_evidence.managed_vector_chunk_rows"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=arwdDxtm/postgres"
+ ],
+ "kind": "r",
+ "owner": "postgres",
+ "columns": [
+ {
+ "acl": null,
+ "name": "content",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "content_hash",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "default": "now()",
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "document_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "embedding",
+ "type": "extensions.vector(1536)",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "field_type",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "id",
+ "type": "uuid",
+ "default": "gen_random_uuid()",
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "index_generation_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "metadata",
+ "type": "jsonb",
+ "default": "'{}'::jsonb",
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "owner_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "packet_identity",
+ "type": "jsonb",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "prepared_sha256",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "search_tsv",
+ "type": "tsvector",
+ "default": "to_tsvector('english'::regconfig, content)",
+ "identity": "",
+ "not_null": false,
+ "generated": "s"
+ },
+ {
+ "acl": null,
+ "name": "source_chunk_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ }
+ ],
+ "indexes": [
+ {
+ "live": true,
+ "ready": true,
+ "valid": true,
+ "identity": "ingestion_generation_evidence.managed_vector_field_rows_pkey",
+ "definition": "CREATE UNIQUE INDEX managed_vector_field_rows_pkey ON ingestion_generation_evidence.managed_vector_field_rows USING btree (id)"
+ }
+ ],
+ "policies": null,
+ "sequence": null,
+ "triggers": [
+ {
+ "name": "evidence_immutable",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_immutable BEFORE DELETE OR UPDATE ON ingestion_generation_evidence.managed_vector_field_rows FOR EACH ROW EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ },
+ {
+ "name": "evidence_no_truncate",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_no_truncate BEFORE TRUNCATE ON ingestion_generation_evidence.managed_vector_field_rows FOR EACH STATEMENT EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ }
+ ],
+ "reloptions": null,
+ "rls_forced": false,
+ "constraints": [
+ {
+ "name": "document_embedding_fields_content_not_blank",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((length(btrim(content)) > 0))"
+ },
+ {
+ "name": "document_embedding_fields_field_type_check",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((field_type = ANY (ARRAY['document_title'::text, 'document_summary'::text, 'section_context'::text, 'memory_card'::text, 'chunk_high_yield'::text, 'table_row'::text, 'image_caption'::text, 'clinical_action'::text, 'threshold_fact'::text])))"
+ },
+ {
+ "name": "managed_vector_field_rows_document_id_index_generation_id_fkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "FOREIGN KEY (document_id, index_generation_id) REFERENCES ingestion_generation_evidence.managed_vector_packets(document_id, generation_id)"
+ },
+ {
+ "name": "managed_vector_field_rows_pkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "PRIMARY KEY (id)"
+ }
+ ],
+ "rls_enabled": true,
+ "view_definition": null
+ },
+ "identity": "relation:ingestion_generation_evidence.managed_vector_field_rows"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=arwdDxtm/postgres"
+ ],
+ "kind": "r",
+ "owner": "postgres",
+ "columns": [
+ {
+ "acl": null,
+ "name": "artifact_bytes",
+ "type": "bytea",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "byte_length",
+ "type": "bigint",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "document_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "family",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "generation_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "row_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "sha256",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ }
+ ],
+ "indexes": [
+ {
+ "live": true,
+ "ready": true,
+ "valid": true,
+ "identity": "ingestion_generation_evidence.managed_vector_final_receipts_pkey",
+ "definition": "CREATE UNIQUE INDEX managed_vector_final_receipts_pkey ON ingestion_generation_evidence.managed_vector_final_receipts USING btree (document_id, generation_id, family, row_id)"
+ }
+ ],
+ "policies": null,
+ "sequence": null,
+ "triggers": [
+ {
+ "name": "evidence_immutable",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_immutable BEFORE DELETE OR UPDATE ON ingestion_generation_evidence.managed_vector_final_receipts FOR EACH ROW EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ },
+ {
+ "name": "evidence_no_truncate",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_no_truncate BEFORE TRUNCATE ON ingestion_generation_evidence.managed_vector_final_receipts FOR EACH STATEMENT EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ }
+ ],
+ "reloptions": null,
+ "rls_forced": false,
+ "constraints": [
+ {
+ "name": "managed_vector_final_receipts_check",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((byte_length = octet_length(artifact_bytes)))"
+ },
+ {
+ "name": "managed_vector_final_receipts_check1",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((sha256 = ('sha256:'::text || encode(extensions.digest(artifact_bytes, 'sha256'::text), 'hex'::text))))"
+ },
+ {
+ "name": "managed_vector_final_receipts_document_id_generation_id_fkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "FOREIGN KEY (document_id, generation_id) REFERENCES ingestion_generation_evidence.managed_vector_packets(document_id, generation_id)"
+ },
+ {
+ "name": "managed_vector_final_receipts_family_check",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((family = ANY (ARRAY['chunks'::text, 'embedding_fields'::text, 'index_units'::text])))"
+ },
+ {
+ "name": "managed_vector_final_receipts_pkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "PRIMARY KEY (document_id, generation_id, family, row_id)"
+ }
+ ],
+ "rls_enabled": true,
+ "view_definition": null
+ },
+ "identity": "relation:ingestion_generation_evidence.managed_vector_final_receipts"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=arwdDxtm/postgres"
+ ],
+ "kind": "r",
+ "owner": "postgres",
+ "columns": [
+ {
+ "acl": null,
+ "name": "artifact_generation_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "content",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "default": "now()",
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "document_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "embedding",
+ "type": "extensions.vector(1536)",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "extraction_mode",
+ "type": "text",
+ "default": "'deterministic'::text",
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "heading_path",
+ "type": "text[]",
+ "default": "'{}'::text[]",
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "id",
+ "type": "uuid",
+ "default": "gen_random_uuid()",
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "index_generation_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "metadata",
+ "type": "jsonb",
+ "default": "'{}'::jsonb",
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "normalized_terms",
+ "type": "text[]",
+ "default": "'{}'::text[]",
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "owner_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "packet_identity",
+ "type": "jsonb",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "page_end",
+ "type": "integer",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "page_start",
+ "type": "integer",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "prepared_sha256",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "producer",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "quality_score",
+ "type": "real",
+ "default": "0.7",
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "search_tsv",
+ "type": "tsvector",
+ "default": "to_tsvector('english'::regconfig, ((((COALESCE(unit_type, ''::text) || ' '::text) || COALESCE(title, ''::text)) || ' '::text) || COALESCE(content, ''::text)))",
+ "identity": "",
+ "not_null": false,
+ "generated": "s"
+ },
+ {
+ "acl": null,
+ "name": "source_chunk_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "source_image_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "source_span",
+ "type": "jsonb",
+ "default": null,
+ "identity": "",
+ "not_null": false,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "title",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "unit_type",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "default": "now()",
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ }
+ ],
+ "indexes": [
+ {
+ "live": true,
+ "ready": true,
+ "valid": true,
+ "identity": "ingestion_generation_evidence.managed_vector_index_rows_pkey",
+ "definition": "CREATE UNIQUE INDEX managed_vector_index_rows_pkey ON ingestion_generation_evidence.managed_vector_index_rows USING btree (id)"
+ }
+ ],
+ "policies": null,
+ "sequence": null,
+ "triggers": [
+ {
+ "name": "evidence_immutable",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_immutable BEFORE DELETE OR UPDATE ON ingestion_generation_evidence.managed_vector_index_rows FOR EACH ROW EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ },
+ {
+ "name": "evidence_no_truncate",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_no_truncate BEFORE TRUNCATE ON ingestion_generation_evidence.managed_vector_index_rows FOR EACH STATEMENT EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ }
+ ],
+ "reloptions": null,
+ "rls_forced": false,
+ "constraints": [
+ {
+ "name": "document_index_units_content_not_blank",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((length(btrim(content)) > 0))"
+ },
+ {
+ "name": "document_index_units_extraction_mode_check",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((extraction_mode = ANY (ARRAY['deterministic'::text, 'model_heavy'::text, 'hybrid'::text])))"
+ },
+ {
+ "name": "document_index_units_quality_score_check",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK (((quality_score >= (0)::double precision) AND (quality_score <= (1)::double precision)))"
+ },
+ {
+ "name": "document_index_units_unit_type_check",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((unit_type = ANY (ARRAY['document_profile'::text, 'section_summary'::text, 'page_text'::text, 'chunk_evidence'::text, 'table_fact'::text, 'askable_question'::text, 'clinical_fact'::text, 'threshold'::text, 'workflow_step'::text, 'medication_monitoring'::text, 'alias'::text, 'vocabulary_term'::text, 'visual_summary'::text, 'flowchart_step'::text, 'diagram_decision'::text, 'risk_matrix_cell'::text, 'medication_chart_row'::text, 'chart_finding'::text, 'visual_askable_question'::text, 'table_threshold'::text])))"
+ },
+ {
+ "name": "managed_vector_index_rows_document_id_index_generation_id_fkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "FOREIGN KEY (document_id, index_generation_id) REFERENCES ingestion_generation_evidence.managed_vector_packets(document_id, generation_id)"
+ },
+ {
+ "name": "managed_vector_index_rows_pkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "PRIMARY KEY (id)"
+ }
+ ],
+ "rls_enabled": true,
+ "view_definition": null
+ },
+ "identity": "relation:ingestion_generation_evidence.managed_vector_index_rows"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=arwdDxtm/postgres"
+ ],
+ "kind": "r",
+ "owner": "postgres",
+ "columns": [
+ {
+ "acl": null,
+ "name": "document_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "generation_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "identity",
+ "type": "jsonb",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "plan_digest",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "population_bytes",
+ "type": "bytea",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "population_sha256",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "request",
+ "type": "jsonb",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ }
+ ],
+ "indexes": [
+ {
+ "live": true,
+ "ready": true,
+ "valid": true,
+ "identity": "ingestion_generation_evidence.managed_vector_packets_pkey",
+ "definition": "CREATE UNIQUE INDEX managed_vector_packets_pkey ON ingestion_generation_evidence.managed_vector_packets USING btree (document_id, generation_id)"
+ }
+ ],
+ "policies": null,
+ "sequence": null,
+ "triggers": [
+ {
+ "name": "evidence_immutable",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_immutable BEFORE DELETE OR UPDATE ON ingestion_generation_evidence.managed_vector_packets FOR EACH ROW EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ },
+ {
+ "name": "evidence_no_truncate",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_no_truncate BEFORE TRUNCATE ON ingestion_generation_evidence.managed_vector_packets FOR EACH STATEMENT EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ }
+ ],
+ "reloptions": null,
+ "rls_forced": false,
+ "constraints": [
+ {
+ "name": "managed_vector_packets_check",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((population_sha256 = ('sha256:'::text || encode(extensions.digest(population_bytes, 'sha256'::text), 'hex'::text))))"
+ },
+ {
+ "name": "managed_vector_packets_document_id_generation_id_fkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "FOREIGN KEY (document_id, generation_id) REFERENCES ingestion_generation_evidence.candidates(document_id, generation_id)"
+ },
+ {
+ "name": "managed_vector_packets_pkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "PRIMARY KEY (document_id, generation_id)"
+ },
+ {
+ "name": "managed_vector_packets_plan_digest_check",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((plan_digest ~ '^sha256:[0-9a-f]{64}$'::text))"
+ }
+ ],
+ "rls_enabled": true,
+ "view_definition": null
+ },
+ "identity": "relation:ingestion_generation_evidence.managed_vector_packets"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=arwdDxtm/postgres"
+ ],
+ "kind": "r",
+ "owner": "postgres",
+ "columns": [
+ {
+ "acl": null,
+ "name": "document_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "family",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "generation_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "group_key",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "outcome",
+ "type": "jsonb",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "unit_key",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ }
+ ],
+ "indexes": [
+ {
+ "live": true,
+ "ready": true,
+ "valid": true,
+ "identity": "ingestion_generation_evidence.outcomes_pkey",
+ "definition": "CREATE UNIQUE INDEX outcomes_pkey ON ingestion_generation_evidence.outcomes USING btree (document_id, generation_id, family, unit_key)"
+ }
+ ],
+ "policies": null,
+ "sequence": null,
+ "triggers": [
+ {
+ "name": "evidence_immutable",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_immutable BEFORE DELETE OR UPDATE ON ingestion_generation_evidence.outcomes FOR EACH ROW EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ },
+ {
+ "name": "evidence_no_truncate",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_no_truncate BEFORE TRUNCATE ON ingestion_generation_evidence.outcomes FOR EACH STATEMENT EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ }
+ ],
+ "reloptions": null,
+ "rls_forced": false,
+ "constraints": [
+ {
+ "name": "outcomes_document_id_generation_id_family_unit_key_fkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "FOREIGN KEY (document_id, generation_id, family, unit_key) REFERENCES ingestion_generation_evidence.unit_declarations(document_id, generation_id, family, unit_key)"
+ },
+ {
+ "name": "outcomes_pkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "PRIMARY KEY (document_id, generation_id, family, unit_key)"
+ }
+ ],
+ "rls_enabled": true,
+ "view_definition": null
+ },
+ "identity": "relation:ingestion_generation_evidence.outcomes"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=arwdDxtm/postgres"
+ ],
+ "kind": "r",
+ "owner": "postgres",
+ "columns": [
+ {
+ "acl": null,
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "default": "clock_timestamp()",
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "document_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "generation_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "snapshot",
+ "type": "jsonb",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "snapshot_digest",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ }
+ ],
+ "indexes": [
+ {
+ "live": true,
+ "ready": true,
+ "valid": true,
+ "identity": "ingestion_generation_evidence.prerequisite_accounting_pkey",
+ "definition": "CREATE UNIQUE INDEX prerequisite_accounting_pkey ON ingestion_generation_evidence.prerequisite_accounting USING btree (document_id, generation_id, snapshot_digest)"
+ }
+ ],
+ "policies": null,
+ "sequence": null,
+ "triggers": [
+ {
+ "name": "evidence_immutable",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_immutable BEFORE DELETE OR UPDATE ON ingestion_generation_evidence.prerequisite_accounting FOR EACH ROW EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ },
+ {
+ "name": "evidence_no_truncate",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_no_truncate BEFORE TRUNCATE ON ingestion_generation_evidence.prerequisite_accounting FOR EACH STATEMENT EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ }
+ ],
+ "reloptions": null,
+ "rls_forced": false,
+ "constraints": [
+ {
+ "name": "prerequisite_accounting_document_id_generation_id_fkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "FOREIGN KEY (document_id, generation_id) REFERENCES ingestion_generation_evidence.candidates(document_id, generation_id)"
+ },
+ {
+ "name": "prerequisite_accounting_pkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "PRIMARY KEY (document_id, generation_id, snapshot_digest)"
+ }
+ ],
+ "rls_enabled": true,
+ "view_definition": null
+ },
+ "identity": "relation:ingestion_generation_evidence.prerequisite_accounting"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=arwdDxtm/postgres"
+ ],
+ "kind": "r",
+ "owner": "postgres",
+ "columns": [
+ {
+ "acl": null,
+ "name": "declaration",
+ "type": "jsonb",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "document_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "family",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "first_lease_token",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "generation_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "group_key",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ }
+ ],
+ "indexes": [
+ {
+ "live": true,
+ "ready": true,
+ "valid": true,
+ "identity": "ingestion_generation_evidence.producer_declarations_pkey",
+ "definition": "CREATE UNIQUE INDEX producer_declarations_pkey ON ingestion_generation_evidence.producer_declarations USING btree (document_id, generation_id, family, group_key)"
+ }
+ ],
+ "policies": null,
+ "sequence": null,
+ "triggers": [
+ {
+ "name": "evidence_immutable",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_immutable BEFORE DELETE OR UPDATE ON ingestion_generation_evidence.producer_declarations FOR EACH ROW EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ },
+ {
+ "name": "evidence_no_truncate",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_no_truncate BEFORE TRUNCATE ON ingestion_generation_evidence.producer_declarations FOR EACH STATEMENT EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ }
+ ],
+ "reloptions": null,
+ "rls_forced": false,
+ "constraints": [
+ {
+ "name": "producer_declarations_document_id_generation_id_fkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "FOREIGN KEY (document_id, generation_id) REFERENCES ingestion_generation_evidence.candidates(document_id, generation_id)"
+ },
+ {
+ "name": "producer_declarations_pkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "PRIMARY KEY (document_id, generation_id, family, group_key)"
+ }
+ ],
+ "rls_enabled": true,
+ "view_definition": null
+ },
+ "identity": "relation:ingestion_generation_evidence.producer_declarations"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=arwdDxtm/postgres"
+ ],
+ "kind": "r",
+ "owner": "postgres",
+ "columns": [
+ {
+ "acl": null,
+ "name": "artifact_bytes",
+ "type": "bytea",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "byte_length",
+ "type": "bigint",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "document_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "family",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "generation_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "group_key",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "locator",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "representation",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "sha256",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "source_sha256",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "unit_key",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ }
+ ],
+ "indexes": [
+ {
+ "live": true,
+ "ready": true,
+ "valid": true,
+ "identity": "ingestion_generation_evidence.retained_artifacts_pkey",
+ "definition": "CREATE UNIQUE INDEX retained_artifacts_pkey ON ingestion_generation_evidence.retained_artifacts USING btree (document_id, generation_id, family, unit_key)"
+ }
+ ],
+ "policies": null,
+ "sequence": null,
+ "triggers": [
+ {
+ "name": "evidence_immutable",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_immutable BEFORE DELETE OR UPDATE ON ingestion_generation_evidence.retained_artifacts FOR EACH ROW EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ },
+ {
+ "name": "evidence_no_truncate",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_no_truncate BEFORE TRUNCATE ON ingestion_generation_evidence.retained_artifacts FOR EACH STATEMENT EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ }
+ ],
+ "reloptions": null,
+ "rls_forced": false,
+ "constraints": [
+ {
+ "name": "retained_artifacts_check",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((byte_length = octet_length(artifact_bytes)))"
+ },
+ {
+ "name": "retained_artifacts_check1",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((sha256 = ('sha256:'::text || encode(extensions.digest(artifact_bytes, 'sha256'::text), 'hex'::text))))"
+ },
+ {
+ "name": "retained_artifacts_document_id_generation_id_family_unit_k_fkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "FOREIGN KEY (document_id, generation_id, family, unit_key) REFERENCES ingestion_generation_evidence.unit_declarations(document_id, generation_id, family, unit_key)"
+ },
+ {
+ "name": "retained_artifacts_pkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "PRIMARY KEY (document_id, generation_id, family, unit_key)"
+ }
+ ],
+ "rls_enabled": true,
+ "view_definition": null
+ },
+ "identity": "relation:ingestion_generation_evidence.retained_artifacts"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=arwdDxtm/postgres"
+ ],
+ "kind": "r",
+ "owner": "postgres",
+ "columns": [
+ {
+ "acl": null,
+ "name": "artifact_bytes",
+ "type": "bytea",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "byte_length",
+ "type": "bigint",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "document_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "family",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "generation_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "phase",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "projection_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "row_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "sha256",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ }
+ ],
+ "indexes": [
+ {
+ "live": true,
+ "ready": true,
+ "valid": true,
+ "identity": "ingestion_generation_evidence.scoped_projection_artifacts_pkey",
+ "definition": "CREATE UNIQUE INDEX scoped_projection_artifacts_pkey ON ingestion_generation_evidence.scoped_projection_artifacts USING btree (document_id, generation_id, projection_id, family, row_id, phase)"
+ }
+ ],
+ "policies": null,
+ "sequence": null,
+ "triggers": [
+ {
+ "name": "evidence_immutable",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_immutable BEFORE DELETE OR UPDATE ON ingestion_generation_evidence.scoped_projection_artifacts FOR EACH ROW EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ },
+ {
+ "name": "evidence_no_truncate",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_no_truncate BEFORE TRUNCATE ON ingestion_generation_evidence.scoped_projection_artifacts FOR EACH STATEMENT EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ }
+ ],
+ "reloptions": null,
+ "rls_forced": false,
+ "constraints": [
+ {
+ "name": "scoped_projection_artifacts_check",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK (((byte_length = octet_length(artifact_bytes)) AND ((byte_length >= 1) AND (byte_length <= 2097152))))"
+ },
+ {
+ "name": "scoped_projection_artifacts_check1",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((sha256 = ('sha256:'::text || encode(extensions.digest(artifact_bytes, 'sha256'::text), 'hex'::text))))"
+ },
+ {
+ "name": "scoped_projection_artifacts_document_id_generation_id_proj_fkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "FOREIGN KEY (document_id, generation_id, projection_id) REFERENCES ingestion_generation_evidence.scoped_projection_expectations(document_id, generation_id, projection_id)"
+ },
+ {
+ "name": "scoped_projection_artifacts_family_check",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((family = ANY (ARRAY['images'::text, 'chunks'::text, 'table_facts'::text])))"
+ },
+ {
+ "name": "scoped_projection_artifacts_phase_check",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((phase = ANY (ARRAY['prepared'::text, 'final'::text])))"
+ },
+ {
+ "name": "scoped_projection_artifacts_pkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "PRIMARY KEY (document_id, generation_id, projection_id, family, row_id, phase)"
+ }
+ ],
+ "rls_enabled": true,
+ "view_definition": null
+ },
+ "identity": "relation:ingestion_generation_evidence.scoped_projection_artifacts"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=arwdDxtm/postgres"
+ ],
+ "kind": "r",
+ "owner": "postgres",
+ "columns": [
+ {
+ "acl": null,
+ "name": "byte_length",
+ "type": "bigint",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "document_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "generation_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "manifest",
+ "type": "jsonb",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "manifest_bytes",
+ "type": "bytea",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "projection_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "sha256",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ }
+ ],
+ "indexes": [
+ {
+ "live": true,
+ "ready": true,
+ "valid": true,
+ "identity": "ingestion_generation_evidence.scoped_projection_expectations_pkey",
+ "definition": "CREATE UNIQUE INDEX scoped_projection_expectations_pkey ON ingestion_generation_evidence.scoped_projection_expectations USING btree (document_id, generation_id, projection_id)"
+ }
+ ],
+ "policies": null,
+ "sequence": null,
+ "triggers": [
+ {
+ "name": "evidence_immutable",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_immutable BEFORE DELETE OR UPDATE ON ingestion_generation_evidence.scoped_projection_expectations FOR EACH ROW EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ },
+ {
+ "name": "evidence_no_truncate",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_no_truncate BEFORE TRUNCATE ON ingestion_generation_evidence.scoped_projection_expectations FOR EACH STATEMENT EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ }
+ ],
+ "reloptions": null,
+ "rls_forced": false,
+ "constraints": [
+ {
+ "name": "scoped_projection_expectations_check",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK (((byte_length = octet_length(manifest_bytes)) AND ((byte_length >= 1) AND (byte_length <= 2097152))))"
+ },
+ {
+ "name": "scoped_projection_expectations_check1",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((sha256 = ('sha256:'::text || encode(extensions.digest(manifest_bytes, 'sha256'::text), 'hex'::text))))"
+ },
+ {
+ "name": "scoped_projection_expectations_document_id_generation_id_fkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "FOREIGN KEY (document_id, generation_id) REFERENCES ingestion_generation_evidence.candidates(document_id, generation_id)"
+ },
+ {
+ "name": "scoped_projection_expectations_pkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "PRIMARY KEY (document_id, generation_id, projection_id)"
+ }
+ ],
+ "rls_enabled": true,
+ "view_definition": null
+ },
+ "identity": "relation:ingestion_generation_evidence.scoped_projection_expectations"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=arwdDxtm/postgres"
+ ],
+ "kind": "r",
+ "owner": "postgres",
+ "columns": [
+ {
+ "acl": null,
+ "name": "document_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "family",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "generation_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "projection_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "row_data",
+ "type": "jsonb",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "row_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ }
+ ],
+ "indexes": [
+ {
+ "live": true,
+ "ready": true,
+ "valid": true,
+ "identity": "ingestion_generation_evidence.scoped_projection_rows_pkey",
+ "definition": "CREATE UNIQUE INDEX scoped_projection_rows_pkey ON ingestion_generation_evidence.scoped_projection_rows USING btree (document_id, generation_id, projection_id, family, row_id)"
+ }
+ ],
+ "policies": null,
+ "sequence": null,
+ "triggers": [
+ {
+ "name": "evidence_immutable",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_immutable BEFORE DELETE OR UPDATE ON ingestion_generation_evidence.scoped_projection_rows FOR EACH ROW EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ },
+ {
+ "name": "evidence_no_truncate",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_no_truncate BEFORE TRUNCATE ON ingestion_generation_evidence.scoped_projection_rows FOR EACH STATEMENT EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ }
+ ],
+ "reloptions": null,
+ "rls_forced": false,
+ "constraints": [
+ {
+ "name": "scoped_projection_rows_document_id_generation_id_projectio_fkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "FOREIGN KEY (document_id, generation_id, projection_id) REFERENCES ingestion_generation_evidence.scoped_projection_expectations(document_id, generation_id, projection_id)"
+ },
+ {
+ "name": "scoped_projection_rows_family_check",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((family = ANY (ARRAY['images'::text, 'chunks'::text, 'table_facts'::text])))"
+ },
+ {
+ "name": "scoped_projection_rows_pkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "PRIMARY KEY (document_id, generation_id, projection_id, family, row_id)"
+ }
+ ],
+ "rls_enabled": true,
+ "view_definition": null
+ },
+ "identity": "relation:ingestion_generation_evidence.scoped_projection_rows"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=arwdDxtm/postgres"
+ ],
+ "kind": "r",
+ "owner": "postgres",
+ "columns": [
+ {
+ "acl": null,
+ "name": "declaration",
+ "type": "jsonb",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "document_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "family",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "generation_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "group_key",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "unit_key",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ }
+ ],
+ "indexes": [
+ {
+ "live": true,
+ "ready": true,
+ "valid": true,
+ "identity": "ingestion_generation_evidence.unit_declarations_pkey",
+ "definition": "CREATE UNIQUE INDEX unit_declarations_pkey ON ingestion_generation_evidence.unit_declarations USING btree (document_id, generation_id, family, unit_key)"
+ }
+ ],
+ "policies": null,
+ "sequence": null,
+ "triggers": [
+ {
+ "name": "evidence_immutable",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_immutable BEFORE DELETE OR UPDATE ON ingestion_generation_evidence.unit_declarations FOR EACH ROW EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ },
+ {
+ "name": "evidence_no_truncate",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_no_truncate BEFORE TRUNCATE ON ingestion_generation_evidence.unit_declarations FOR EACH STATEMENT EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ }
+ ],
+ "reloptions": null,
+ "rls_forced": false,
+ "constraints": [
+ {
+ "name": "unit_declarations_document_id_generation_id_family_group_k_fkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "FOREIGN KEY (document_id, generation_id, family, group_key) REFERENCES ingestion_generation_evidence.producer_declarations(document_id, generation_id, family, group_key)"
+ },
+ {
+ "name": "unit_declarations_pkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "PRIMARY KEY (document_id, generation_id, family, unit_key)"
+ }
+ ],
+ "rls_enabled": true,
+ "view_definition": null
+ },
+ "identity": "relation:ingestion_generation_evidence.unit_declarations"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=arwdDxtm/postgres"
+ ],
+ "kind": "r",
+ "owner": "postgres",
+ "columns": [
+ {
+ "acl": null,
+ "name": "document_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "generation_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "issued_rows",
+ "type": "jsonb",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "manifest",
+ "type": "jsonb",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "manifest_bytes",
+ "type": "bytea",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "manifest_sha256",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "parent_bytes",
+ "type": "bytea",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "parent_projection_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "parent_sha256",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "projection_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "seed_bytes",
+ "type": "bytea",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "seed_sha256",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ }
+ ],
+ "indexes": [
+ {
+ "live": true,
+ "ready": true,
+ "valid": true,
+ "identity": "ingestion_generation_evidence.visual_unit_expectations_pkey",
+ "definition": "CREATE UNIQUE INDEX visual_unit_expectations_pkey ON ingestion_generation_evidence.visual_unit_expectations USING btree (document_id, generation_id, projection_id)"
+ }
+ ],
+ "policies": null,
+ "sequence": null,
+ "triggers": [
+ {
+ "name": "evidence_immutable",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_immutable BEFORE DELETE OR UPDATE ON ingestion_generation_evidence.visual_unit_expectations FOR EACH ROW EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ },
+ {
+ "name": "evidence_no_truncate",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_no_truncate BEFORE TRUNCATE ON ingestion_generation_evidence.visual_unit_expectations FOR EACH STATEMENT EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ }
+ ],
+ "reloptions": null,
+ "rls_forced": false,
+ "constraints": [
+ {
+ "name": "visual_unit_expectations_check",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((((octet_length(seed_bytes) >= 1) AND (octet_length(seed_bytes) <= 2097152)) AND (seed_sha256 = ('sha256:'::text || encode(extensions.digest(seed_bytes, 'sha256'::text), 'hex'::text)))))"
+ },
+ {
+ "name": "visual_unit_expectations_check1",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((((octet_length(parent_bytes) >= 1) AND (octet_length(parent_bytes) <= 2097152)) AND (parent_sha256 = ('sha256:'::text || encode(extensions.digest(parent_bytes, 'sha256'::text), 'hex'::text)))))"
+ },
+ {
+ "name": "visual_unit_expectations_check2",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((((octet_length(manifest_bytes) >= 1) AND (octet_length(manifest_bytes) <= 2097152)) AND (manifest_sha256 = ('sha256:'::text || encode(extensions.digest(manifest_bytes, 'sha256'::text), 'hex'::text)))))"
+ },
+ {
+ "name": "visual_unit_expectations_document_id_generation_id_fkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "FOREIGN KEY (document_id, generation_id) REFERENCES ingestion_generation_evidence.candidates(document_id, generation_id)"
+ },
+ {
+ "name": "visual_unit_expectations_document_id_generation_id_parent__fkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "FOREIGN KEY (document_id, generation_id, parent_projection_id) REFERENCES ingestion_generation_evidence.scoped_projection_expectations(document_id, generation_id, projection_id)"
+ },
+ {
+ "name": "visual_unit_expectations_issued_rows_check",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK (((jsonb_typeof(issued_rows) = 'array'::text) AND (jsonb_array_length(issued_rows) = 4)))"
+ },
+ {
+ "name": "visual_unit_expectations_pkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "PRIMARY KEY (document_id, generation_id, projection_id)"
+ }
+ ],
+ "rls_enabled": true,
+ "view_definition": null
+ },
+ "identity": "relation:ingestion_generation_evidence.visual_unit_expectations"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=arwdDxtm/postgres"
+ ],
+ "kind": "r",
+ "owner": "postgres",
+ "columns": [
+ {
+ "acl": null,
+ "name": "document_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "generation_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "ordinal",
+ "type": "integer",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "projection_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "row_data",
+ "type": "jsonb",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "acl": null,
+ "name": "row_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ }
+ ],
+ "indexes": [
+ {
+ "live": true,
+ "ready": true,
+ "valid": true,
+ "identity": "ingestion_generation_evidence.visual_unit_rows_document_id_generation_id_projection_id_ro_key",
+ "definition": "CREATE UNIQUE INDEX visual_unit_rows_document_id_generation_id_projection_id_ro_key ON ingestion_generation_evidence.visual_unit_rows USING btree (document_id, generation_id, projection_id, row_id)"
+ },
+ {
+ "live": true,
+ "ready": true,
+ "valid": true,
+ "identity": "ingestion_generation_evidence.visual_unit_rows_pkey",
+ "definition": "CREATE UNIQUE INDEX visual_unit_rows_pkey ON ingestion_generation_evidence.visual_unit_rows USING btree (document_id, generation_id, projection_id, ordinal)"
+ }
+ ],
+ "policies": null,
+ "sequence": null,
+ "triggers": [
+ {
+ "name": "evidence_immutable",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_immutable BEFORE DELETE OR UPDATE ON ingestion_generation_evidence.visual_unit_rows FOR EACH ROW EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ },
+ {
+ "name": "evidence_no_truncate",
+ "enabled": "O",
+ "definition": "CREATE TRIGGER evidence_no_truncate BEFORE TRUNCATE ON ingestion_generation_evidence.visual_unit_rows FOR EACH STATEMENT EXECUTE FUNCTION ingestion_generation_evidence.immutable()"
+ }
+ ],
+ "reloptions": null,
+ "rls_forced": false,
+ "constraints": [
+ {
+ "name": "visual_unit_rows_document_id_generation_id_projection_id_fkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "FOREIGN KEY (document_id, generation_id, projection_id) REFERENCES ingestion_generation_evidence.visual_unit_expectations(document_id, generation_id, projection_id)"
+ },
+ {
+ "name": "visual_unit_rows_document_id_generation_id_projection_id_ro_key",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "UNIQUE (document_id, generation_id, projection_id, row_id)"
+ },
+ {
+ "name": "visual_unit_rows_ordinal_check",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK (((ordinal >= 1) AND (ordinal <= 4)))"
+ },
+ {
+ "name": "visual_unit_rows_pkey",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "PRIMARY KEY (document_id, generation_id, projection_id, ordinal)"
+ },
+ {
+ "name": "visual_unit_rows_row_data_check",
+ "deferred": false,
+ "validated": true,
+ "deferrable": false,
+ "definition": "CHECK ((jsonb_typeof(row_data) = 'object'::text))"
+ }
+ ],
+ "rls_enabled": true,
+ "view_definition": null
+ },
+ "identity": "relation:ingestion_generation_evidence.visual_unit_rows"
+ },
+ {
+ "state": {
+ "acl": [
+ "postgres=UC/postgres"
+ ],
+ "owner": "postgres"
+ },
+ "identity": "schema:ingestion_generation_evidence"
+ }
+ ],
"storage_buckets": [
{
"id": "clinical-documents",
@@ -27039,5 +30688,12 @@
"snapshot_version": 2,
"migration_history": [],
"migration_history_probe": "no_history_table"
+ },
+ "replay_provenance": {
+ "method": "Exact pinned OCI filesystem flattened to avoid VFS layer-copy amplification; explicit synthetic Auth claims adapter and repository Storage scaffold; no hosted APIs",
+ "rootfs_sha256": "d6e5d7d6f12615f4726e4be8ab1166b6857c3f7f85e1e8b62008fd2665067b2e",
+ "engine": "PostgreSQL17.6",
+ "network": "none",
+ "source_manifest_digest": "sha256:72b610893752a132325a1c2791ce879f7733022524bcecab4e81918af9854cfd"
}
}
diff --git a/supabase/migrations/20261105144240_audit_private_evidence_lifecycle_and_drift.sql b/supabase/migrations/20261105144240_audit_private_evidence_lifecycle_and_drift.sql
new file mode 100644
index 0000000000..63df3f9617
--- /dev/null
+++ b/supabase/migrations/20261105144240_audit_private_evidence_lifecycle_and_drift.sql
@@ -0,0 +1,472 @@
+-- Permanent document removal also removes reconstructable ingestion evidence.
+-- Active/rollback evidence remains immutable while its parent document exists.
+-- This drafts no age-based retention, legal hold, historical orphan sweep or backup purge.
+set local lock_timeout = '5s';
+set local statement_timeout = '60s';
+
+create or replace function ingestion_generation_evidence.immutable() returns trigger
+language plpgsql set search_path='' as $$ declare worker text; attempt jsonb; begin
+ -- A real parent deletion is the only lifecycle exception. No caller-controlled bypass.
+ if tg_op='DELETE' then
+ if tg_table_schema='ingestion_generation_evidence' and tg_level='ROW'
+ and pg_catalog.pg_trigger_depth() > 1 and current_user='postgres'
+ and not exists (select 1 from public.documents d where d.id=old.document_id) then
+ return old;
+ end if;
+ end if;
+ if tg_table_name='candidates' and tg_op='UPDATE' then
+ if old.expected_inventory_digest is null
+ and new.expected_inventory_digest ~ '^sha256:[0-9a-f]{64}$'
+ and to_jsonb(old)-'expected_inventory_digest'=to_jsonb(new)-'expected_inventory_digest'
+ and current_user='postgres' then return new; end if;
+ if current_user='postgres' and old.source_section_layout is null and old.source_section_layout_bytes is null
+ and old.source_section_layout_digest is null and new.source_section_layout is not null
+ and new.source_section_layout_bytes is not null and new.source_section_layout_digest is not null
+ and to_jsonb(old)-array['source_section_layout','source_section_layout_bytes','source_section_layout_digest']
+ =to_jsonb(new)-array['source_section_layout','source_section_layout_bytes','source_section_layout_digest'] then
+ select locked_by into worker from public.ingestion_jobs where id=old.job_id and document_id=old.document_id;
+ attempt:=public.assert_current_ingestion_attempt(jsonb_build_object('job_kind','node','job_id',old.job_id,
+ 'document_id',old.document_id,'worker_id',worker,'lease_token',old.first_lease_token,
+ 'expected_generation_id',old.predecessor_generation_id,'authority_digest',old.authority_digest),old.generation_id);
+ if attempt is not null then
+ perform ingestion_generation_evidence.assert_admin_text_source_section_layout(new::ingestion_generation_evidence.candidates);
+ return new;
+ end if;
+ end if;
+ end if;
+ raise exception 'immutable_ingestion_generation_evidence' using errcode='23514';
+end $$;
+
+alter function ingestion_generation_evidence.immutable() owner to postgres;
+revoke all on function ingestion_generation_evidence.immutable() from public, anon, authenticated, service_role;
+
+create or replace function ingestion_generation_evidence.purge_deleted_document()
+returns trigger
+language plpgsql security definer
+set search_path = ''
+as $purge$
+begin
+ if tg_table_schema <> 'public' or tg_table_name <> 'documents' or tg_op <> 'DELETE'
+ or tg_when <> 'AFTER' or tg_level <> 'ROW'
+ or exists (select 1 from public.documents d where d.id = old.id) then
+ raise exception 'permanent_document_delete_required' using errcode = '23514';
+ end if;
+ -- Child-first order preserves all existing FK and immutability constraints.
+ delete from ingestion_generation_evidence.managed_vector_final_receipts where document_id = old.id;
+ delete from ingestion_generation_evidence.managed_vector_chunk_rows where document_id = old.id;
+ delete from ingestion_generation_evidence.managed_vector_field_rows where document_id = old.id;
+ delete from ingestion_generation_evidence.managed_vector_index_rows where document_id = old.id;
+ delete from ingestion_generation_evidence.scoped_projection_artifacts where document_id = old.id;
+ delete from ingestion_generation_evidence.visual_unit_rows where document_id = old.id;
+ delete from ingestion_generation_evidence.visual_unit_expectations where document_id = old.id;
+ delete from ingestion_generation_evidence.scoped_projection_rows where document_id = old.id;
+ delete from ingestion_generation_evidence.scoped_projection_expectations where document_id = old.id;
+ delete from ingestion_generation_evidence.retained_artifacts where document_id = old.id;
+ delete from ingestion_generation_evidence.outcomes where document_id = old.id;
+ delete from ingestion_generation_evidence.unit_declarations where document_id = old.id;
+ delete from ingestion_generation_evidence.producer_declarations where document_id = old.id;
+ delete from ingestion_generation_evidence.prerequisite_accounting where document_id = old.id;
+ delete from ingestion_generation_evidence.candidate_chunk_rows where document_id = old.id;
+ delete from ingestion_generation_evidence.managed_vector_packets where document_id = old.id;
+ delete from ingestion_generation_evidence.candidates where document_id = old.id;
+ return old;
+end;
+$purge$;
+alter function ingestion_generation_evidence.purge_deleted_document() owner to postgres;
+revoke all on function ingestion_generation_evidence.purge_deleted_document() from public, anon, authenticated, service_role;
+create trigger purge_private_ingestion_evidence_after_document_delete
+ after delete on public.documents for each row
+ execute function ingestion_generation_evidence.purge_deleted_document();
+
+-- Serialize permanent document deletion with ingestion job creation. The
+-- parent row lock conflicts with the FK key-share lock taken by a concurrent
+-- ingestion_jobs insert: either the job commits first and deletion returns an
+-- active_job outcome, or deletion commits first and the insert receives 23503.
+create or replace function public.delete_document_if_idle(
+ p_document_id uuid,
+ p_owner_id uuid,
+ p_document_bucket text,
+ p_image_bucket text
+)
+returns jsonb
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ v_document public.documents%rowtype;
+ v_active_job public.ingestion_jobs%rowtype;
+ v_active_agent_job public.indexing_v3_agent_jobs%rowtype;
+ v_cleanup_job_id uuid;
+ v_image_paths text[] := '{}'::text[];
+ v_chunk_ids uuid[] := '{}'::uuid[];
+begin
+ if p_document_id is null or p_owner_id is null then
+ raise exception 'Document and owner identifiers are required.' using errcode = '22023';
+ end if;
+ if nullif(btrim(p_document_bucket), '') is null or nullif(btrim(p_image_bucket), '') is null then
+ raise exception 'Storage bucket names are required.' using errcode = '22023';
+ end if;
+
+ select d.*
+ into v_document
+ from public.documents d
+ where d.id = p_document_id
+ and d.owner_id = p_owner_id
+ for update;
+
+ if not found then
+ return jsonb_build_object('outcome', 'not_found');
+ end if;
+
+ select j.*
+ into v_active_job
+ from public.ingestion_jobs j
+ where j.document_id = p_document_id
+ and j.status in ('pending', 'processing')
+ order by j.created_at asc, j.id asc
+ limit 1;
+
+ if found then
+ return jsonb_build_object(
+ 'outcome', 'active_job',
+ 'job_id', v_active_job.id,
+ 'job_status', v_active_job.status
+ );
+ end if;
+
+ -- Agent-only work also owns the document. Its FK insert takes the same parent
+ -- key-share lock, so this check is serialized with admission just like node work.
+ select j.* into v_active_agent_job
+ from public.indexing_v3_agent_jobs j
+ where j.document_id = p_document_id and j.status in ('pending', 'processing')
+ order by j.created_at asc, j.id asc limit 1;
+ if found then
+ return jsonb_build_object('outcome', 'active_job', 'job_id', v_active_agent_job.id,
+ 'job_status', v_active_agent_job.status);
+ end if;
+
+ select coalesce(array_agg(distinct i.storage_path order by i.storage_path)
+ filter (where i.storage_path is not null and btrim(i.storage_path) <> ''), '{}'::text[])
+ into v_image_paths
+ from public.document_images i
+ where i.document_id = p_document_id;
+
+ select coalesce(array_agg(c.id order by c.id), '{}'::uuid[])
+ into v_chunk_ids
+ from public.document_chunks c
+ where c.document_id = p_document_id;
+
+ insert into public.storage_cleanup_jobs (
+ owner_id,
+ document_id,
+ document_title,
+ document_bucket,
+ document_paths,
+ image_bucket,
+ image_paths,
+ status,
+ metadata
+ ) values (
+ p_owner_id,
+ p_document_id,
+ v_document.title,
+ p_document_bucket,
+ case
+ when v_document.storage_path is null or btrim(v_document.storage_path) = '' then '{}'::text[]
+ else array[v_document.storage_path]
+ end,
+ p_image_bucket,
+ v_image_paths,
+ 'pending',
+ jsonb_build_object(
+ 'operation', 'permanent_document_delete',
+ 'created_by', 'delete_document_if_idle'
+ )
+ )
+ returning id into v_cleanup_job_id;
+
+ if cardinality(v_chunk_ids) > 0 then
+ delete from public.rag_queries
+ where source_chunk_ids && v_chunk_ids;
+ delete from public.rag_query_misses
+ where top_chunk_ids && v_chunk_ids
+ or cited_chunk_ids && v_chunk_ids;
+ end if;
+
+ delete from public.rag_query_misses
+ where clicked_document_id = p_document_id
+ or expected_document_id = p_document_id;
+
+ delete from public.rag_response_cache
+ where owner_id = p_owner_id
+ and cache_kind in ('search', 'answer');
+
+ delete from public.documents where id = p_document_id;
+
+ return jsonb_build_object(
+ 'outcome', 'deleted',
+ 'cleanup_job_id', v_cleanup_job_id,
+ 'document_title', v_document.title,
+ 'source_path', v_document.storage_path,
+ 'image_paths', to_jsonb(v_image_paths)
+ );
+end;
+$$;
+
+revoke all on function public.delete_document_if_idle(uuid, uuid, text, text)
+ from public, anon, authenticated;
+grant execute on function public.delete_document_if_idle(uuid, uuid, text, text)
+ to service_role;
+
+-- Explicit application-owned private schema only. Platform schemas remain excluded.
+create or replace function ingestion_generation_evidence.schema_drift_inventory()
+returns jsonb
+language sql stable
+set search_path = ''
+as $inventory$
+ with owned_schema as (
+ select n.* from pg_catalog.pg_namespace n
+ where n.nspname = 'ingestion_generation_evidence'
+ and not exists (select 1 from pg_catalog.pg_depend d
+ where d.classid = 'pg_catalog.pg_namespace'::regclass and d.objid = n.oid and d.deptype = 'e')
+ ), objects as (
+ select 'schema:' || n.nspname as identity, jsonb_build_object(
+ 'owner', pg_catalog.pg_get_userbyid(n.nspowner),
+ 'acl', (select jsonb_agg(a.item::text order by a.item::text)
+ from unnest(coalesce(n.nspacl, pg_catalog.acldefault('n', n.nspowner))) a(item))
+ ) as state from owned_schema n
+ union all
+ select 'relation:' || n.nspname || '.' || c.relname, jsonb_build_object(
+ 'kind', c.relkind, 'owner', pg_catalog.pg_get_userbyid(c.relowner),
+ 'acl', (select jsonb_agg(a.item::text order by a.item::text)
+ from unnest(coalesce(c.relacl, pg_catalog.acldefault(case when c.relkind = 'S' then 'S'::"char" else 'r'::"char" end, c.relowner))) a(item)),
+ 'rls_enabled', c.relrowsecurity, 'rls_forced', c.relforcerowsecurity,
+ 'reloptions', (select jsonb_agg(o.opt order by o.opt) from unnest(c.reloptions) o(opt)),
+ 'view_definition', case when c.relkind in ('v','m') then pg_catalog.pg_get_viewdef(c.oid) end,
+ 'sequence', (select to_jsonb(s) - 'seqrelid' from pg_catalog.pg_sequence s where s.seqrelid = c.oid),
+ 'columns', (select jsonb_agg(jsonb_build_object(
+ 'name', a.attname, 'type', pg_catalog.format_type(a.atttypid, a.atttypmod),
+ 'not_null', a.attnotnull, 'identity', a.attidentity, 'generated', a.attgenerated,
+ 'default', pg_catalog.pg_get_expr(ad.adbin, ad.adrelid),
+ 'acl', (select jsonb_agg(x.item::text order by x.item::text) from unnest(a.attacl) x(item))
+ ) order by a.attname) from pg_catalog.pg_attribute a
+ left join pg_catalog.pg_attrdef ad on ad.adrelid = a.attrelid and ad.adnum = a.attnum
+ where a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped),
+ 'constraints', (select jsonb_agg(jsonb_build_object(
+ 'name', x.conname, 'definition', pg_catalog.pg_get_constraintdef(x.oid),
+ 'validated', x.convalidated, 'deferrable', x.condeferrable, 'deferred', x.condeferred
+ ) order by x.conname) from pg_catalog.pg_constraint x where x.conrelid = c.oid),
+ 'indexes', (select jsonb_agg(jsonb_build_object(
+ 'identity', ni.nspname || '.' || ci.relname, 'definition', pg_catalog.pg_get_indexdef(i.indexrelid),
+ 'valid', i.indisvalid, 'ready', i.indisready, 'live', i.indislive
+ ) order by ci.relname) from pg_catalog.pg_index i
+ join pg_catalog.pg_class ci on ci.oid = i.indexrelid
+ join pg_catalog.pg_namespace ni on ni.oid = ci.relnamespace where i.indrelid = c.oid),
+ 'triggers', (select jsonb_agg(jsonb_build_object(
+ 'name', t.tgname, 'enabled', t.tgenabled, 'definition', pg_catalog.pg_get_triggerdef(t.oid)
+ ) order by t.tgname) from pg_catalog.pg_trigger t where t.tgrelid = c.oid and not t.tgisinternal),
+ 'policies', (select jsonb_agg(jsonb_build_object(
+ 'name', p.polname, 'permissive', p.polpermissive, 'command', p.polcmd,
+ 'roles', (select jsonb_agg(case when r.role = 0 then 'public' else pg_catalog.pg_get_userbyid(r.role) end
+ order by case when r.role = 0 then 'public' else pg_catalog.pg_get_userbyid(r.role) end) from unnest(p.polroles) r(role)),
+ 'qual', pg_catalog.pg_get_expr(p.polqual, p.polrelid),
+ 'with_check', pg_catalog.pg_get_expr(p.polwithcheck, p.polrelid)
+ ) order by p.polname) from pg_catalog.pg_policy p where p.polrelid = c.oid)
+ ) from pg_catalog.pg_class c join owned_schema n on n.oid = c.relnamespace
+ where c.relkind in ('r','p','v','m','f','S') and not exists (
+ select 1 from pg_catalog.pg_depend d where d.classid = 'pg_catalog.pg_class'::regclass
+ and d.objid = c.oid and d.deptype = 'e')
+ union all
+ select 'function:' || p.oid::regprocedure::text, jsonb_build_object(
+ 'owner', pg_catalog.pg_get_userbyid(p.proowner),
+ -- Hash complete SQL, including quoted literals. Only normalize platform line endings.
+ 'def_hash', md5(replace(pg_catalog.pg_get_functiondef(p.oid), E'\r\n', E'\n')),
+ 'config', (select jsonb_agg(v.item order by v.item) from unnest(p.proconfig) v(item)),
+ 'security_definer', p.prosecdef,
+ 'acl', (select jsonb_agg(a.item::text order by a.item::text)
+ from unnest(coalesce(p.proacl, pg_catalog.acldefault('f', p.proowner))) a(item))
+ ) from pg_catalog.pg_proc p join owned_schema n on n.oid = p.pronamespace
+ where p.prokind in ('f','p') and not exists (
+ select 1 from pg_catalog.pg_depend d where d.classid = 'pg_catalog.pg_proc'::regclass
+ and d.objid = p.oid and d.deptype = 'e')
+ union all
+ select 'default_acl:' || pg_catalog.pg_get_userbyid(d.defaclrole) || ':' ||
+ coalesce(n.nspname, '*') || ':' || d.defaclobjtype::text, jsonb_build_object(
+ 'acl', (select jsonb_agg(a.item::text order by a.item::text) from unnest(d.defaclacl) a(item))
+ ) from pg_catalog.pg_default_acl d
+ left join pg_catalog.pg_namespace n on n.oid = d.defaclnamespace
+ where d.defaclnamespace in (select oid from owned_schema)
+ or (d.defaclnamespace = 0 and d.defaclrole = 'postgres'::regrole)
+ )
+ select coalesce(jsonb_agg(jsonb_build_object('identity', identity, 'state', state) order by identity), '[]'::jsonb)
+ from objects;
+$inventory$;
+alter function ingestion_generation_evidence.schema_drift_inventory() owner to postgres;
+revoke all on function ingestion_generation_evidence.schema_drift_inventory() from public, anon, authenticated, service_role;
+
+create or replace function public.schema_drift_snapshot()
+returns jsonb
+language plpgsql
+stable
+security definer
+set search_path to ''
+as $$
+declare
+ snapshot jsonb;
+ buckets jsonb := '[]'::jsonb;
+ history jsonb := '[]'::jsonb;
+ history_probe text := 'no_history_table';
+begin
+ select jsonb_build_object(
+ 'snapshot_version', 2,
+ 'captured_at', now(),
+ 'private_objects', ingestion_generation_evidence.schema_drift_inventory(),
+ 'extensions', coalesce((
+ select jsonb_agg(jsonb_build_object('name', e.extname, 'schema', n.nspname) order by e.extname)
+ from pg_extension e
+ join pg_namespace n on n.oid = e.extnamespace
+ where e.extname <> 'plpgsql'
+ ), '[]'::jsonb),
+ 'tables', coalesce((
+ select jsonb_agg(jsonb_build_object(
+ 'name', c.relname,
+ 'rls_enabled', c.relrowsecurity,
+ 'rls_forced', c.relforcerowsecurity,
+ 'reloptions', (select array_agg(o.opt order by o.opt) from unnest(c.reloptions) o(opt)),
+ 'acl', (select array_agg(a.item::text order by a.item::text) from unnest(coalesce(c.relacl, acldefault('r', c.relowner))) a(item)),
+ 'columns', (
+ select jsonb_agg(jsonb_build_object(
+ 'name', att.attname,
+ 'type', format_type(att.atttypid, att.atttypmod),
+ 'not_null', att.attnotnull,
+ 'identity', att.attidentity,
+ 'generated', att.attgenerated,
+ 'default', pg_get_expr(ad.adbin, ad.adrelid)
+ ) order by att.attname)
+ from pg_attribute att
+ left join pg_attrdef ad on ad.adrelid = att.attrelid and ad.adnum = att.attnum
+ where att.attrelid = c.oid and att.attnum > 0 and not att.attisdropped
+ )
+ ) order by c.relname)
+ from pg_class c
+ where c.relnamespace = 'public'::regnamespace and c.relkind = 'r'
+ ), '[]'::jsonb),
+ 'views', coalesce((
+ select jsonb_agg(jsonb_build_object(
+ 'name', c.relname,
+ 'def_hash', md5(regexp_replace(pg_get_viewdef(c.oid), '\s+', '', 'g'))
+ ) order by c.relname)
+ from pg_class c
+ where c.relnamespace = 'public'::regnamespace and c.relkind in ('v', 'm')
+ ), '[]'::jsonb),
+ 'functions', coalesce((
+ select jsonb_agg(jsonb_build_object(
+ 'signature', p.oid::regprocedure::text,
+ -- A '--' starts a line comment only where the text before it on that line holds no
+ -- open quote, so a '--' inside a '...' or "..." value (a URL such as
+ -- Next-Step---Drug-..., a Markdown '| --- |' row) is hashed as content.
+ 'def_hash', md5(regexp_replace(regexp_replace(regexp_replace(pg_get_functiondef(p.oid), '/\*.*?\*/', '', 'gs'), '(^|\n)((?:[^''"\n-]|-(?!-)|''[^''\n]*''|"[^"\n]*")*)--[^\n]*', '\1\2', 'g'), '\s+', '', 'g')),
+ 'acl', (select array_agg(a.item::text order by a.item::text) from unnest(coalesce(p.proacl, acldefault('f', p.proowner))) a(item))
+ ) order by p.oid::regprocedure::text)
+ from pg_proc p
+ where p.pronamespace = 'public'::regnamespace
+ and p.prokind = 'f'
+ and not exists (
+ select 1 from pg_depend dep
+ where dep.classid = 'pg_proc'::regclass and dep.objid = p.oid and dep.deptype = 'e'
+ )
+ ), '[]'::jsonb),
+ 'indexes', coalesce((
+ select jsonb_agg(jsonb_build_object(
+ 'name', ci.relname,
+ 'table', ct.relname,
+ 'def', pg_get_indexdef(ci.oid),
+ 'def_hash', md5(regexp_replace(pg_get_indexdef(ci.oid), '\s+', '', 'g'))
+ ) order by ci.relname)
+ from pg_index i
+ join pg_class ci on ci.oid = i.indexrelid
+ join pg_class ct on ct.oid = i.indrelid
+ where ct.relnamespace = 'public'::regnamespace
+ and ci.relnamespace = 'public'::regnamespace
+ ), '[]'::jsonb),
+ 'policies', coalesce((
+ select jsonb_agg(jsonb_build_object(
+ 'schema', pol.schemaname,
+ 'table', pol.tablename,
+ 'name', pol.policyname,
+ 'permissive', pol.permissive,
+ 'roles', (select array_agg(r.role::text order by r.role::text) from unnest(pol.roles) r(role)),
+ 'cmd', pol.cmd,
+ 'qual', pol.qual,
+ 'with_check', pol.with_check
+ ) order by pol.schemaname, pol.tablename, pol.policyname)
+ from pg_policies pol
+ where pol.schemaname in ('public', 'storage')
+ ), '[]'::jsonb),
+ 'constraints', coalesce((
+ select jsonb_agg(jsonb_build_object(
+ 'table', ct.relname,
+ 'name', con.conname,
+ 'def', pg_get_constraintdef(con.oid)
+ ) order by ct.relname, con.conname)
+ from pg_constraint con
+ join pg_class ct on ct.oid = con.conrelid
+ where con.connamespace = 'public'::regnamespace and ct.relkind = 'r'
+ ), '[]'::jsonb),
+ 'triggers', coalesce((
+ select jsonb_agg(jsonb_build_object(
+ 'table', ct.relname,
+ 'name', t.tgname,
+ 'def', pg_get_triggerdef(t.oid)
+ ) order by ct.relname, t.tgname)
+ from pg_trigger t
+ join pg_class ct on ct.oid = t.tgrelid
+ where ct.relnamespace = 'public'::regnamespace and not t.tgisinternal
+ ), '[]'::jsonb)
+ ) into snapshot;
+
+ if to_regclass('storage.buckets') is not null then
+ execute 'select coalesce(jsonb_agg(jsonb_build_object('
+ || '''id'', b.id, ''public'', b.public, ''file_size_limit'', b.file_size_limit, '
+ || '''allowed_mime_types'', b.allowed_mime_types) order by b.id), ''[]''::jsonb) '
+ || 'from storage.buckets b'
+ into buckets;
+ end if;
+
+ -- Migration-history integrity probe. A version recorded without executed
+ -- statements is the fingerprint of a history repair / mark-applied row and
+ -- must be covered by a fail-fast guard migration + a reviewed allowlist entry
+ -- (docs/database-drift-detection.md, "Guard-migration contract").
+ if to_regclass('supabase_migrations.schema_migrations') is not null then
+ if exists (
+ select 1
+ from pg_attribute att
+ where att.attrelid = 'supabase_migrations.schema_migrations'::regclass
+ and att.attname = 'statements'
+ and att.attnum > 0
+ and not att.attisdropped
+ ) then
+ execute 'select coalesce(jsonb_agg(jsonb_build_object('
+ || '''version'', m.version, ''name'', m.name, '
+ || '''signal'', case when m.statements is null then ''null'' else ''empty'' end'
+ || ') order by m.version), ''[]''::jsonb) '
+ || 'from supabase_migrations.schema_migrations m '
+ || 'where m.statements is null or cardinality(m.statements) = 0'
+ into history;
+ history_probe := 'ok';
+ else
+ history_probe := 'no_statements_column';
+ end if;
+ end if;
+
+ return snapshot || jsonb_build_object(
+ 'storage_buckets', buckets,
+ 'migration_history', history,
+ 'migration_history_probe', history_probe
+ );
+end;
+$$;
+
+revoke execute on function public.schema_drift_snapshot() from public, anon, authenticated;
+grant execute on function public.schema_drift_snapshot() to service_role;
diff --git a/supabase/migrations/20261105144246_audit_cpd_close_coherence.sql b/supabase/migrations/20261105144246_audit_cpd_close_coherence.sql
new file mode 100644
index 0000000000..12a96b302d
--- /dev/null
+++ b/supabase/migrations/20261105144246_audit_cpd_close_coherence.sql
@@ -0,0 +1,132 @@
+-- Bind annual statuses to every evaluator input; retain immutable snapshots and reasoned amendments.
+-- Older application instances lacking inputs fail closed rather than freezing an unchecked evaluation.
+set local lock_timeout = '5s';
+set local statement_timeout = '30s';
+
+-- Requirement task updates and direct allocation writes must serialize with close too.
+-- Lock both sides of a move in year-id order. Account/year cascades find no year and pass.
+create or replace function public.cme_guard_requirement() returns trigger
+language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$
+declare v_year public.cme_years;
+begin
+ for v_year in
+ select y.* from public.cme_years y
+ where (tg_op <> 'INSERT' and y.id = old.year_id and y.owner_id = old.owner_id)
+ or (tg_op <> 'DELETE' and y.id = new.year_id and y.owner_id = new.owner_id)
+ order by y.id for update
+ loop
+ if v_year.closed_at is not null then raise exception 'cme_year_closed'; end if;
+ end loop;
+ if tg_op = 'DELETE' then return old; end if;
+ return new;
+end $$;
+
+create or replace function public.cme_guard_allocation() returns trigger
+language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$
+declare v_entry public.cme_entries; v_closed_at timestamptz;
+begin
+ -- Lock the entry before reading its year in a separate statement. A join locking only
+ -- the year can retain a stale association if an entry moves to another year while waiting.
+ for v_entry in
+ select e.* from public.cme_entries e
+ where (tg_op <> 'INSERT' and e.id = old.entry_id and e.owner_id = old.owner_id)
+ or (tg_op <> 'DELETE' and e.id = new.entry_id and e.owner_id = new.owner_id)
+ order by e.id for share
+ loop
+ select y.closed_at into v_closed_at from public.cme_years y
+ where y.id = v_entry.year_id and y.owner_id = v_entry.owner_id for update;
+ if v_closed_at is not null
+ and not public.cme_entry_amendment_in_progress(v_entry.id, v_entry.owner_id) then
+ raise exception 'cme_year_closed';
+ end if;
+ end loop;
+ if tg_op = 'DELETE' then return old; end if;
+ return new;
+end $$;
+
+create or replace function public.cme_close_year(p_owner_id uuid, p_year_id uuid, p_evaluation jsonb, p_shortfall_note text default null)
+returns jsonb
+language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$
+declare
+ v_year public.cme_years; v_snapshot public.cme_year_snapshots; v_record jsonb; v_inputs jsonb;
+ v_total numeric; v_count integer; v_note text := nullif(btrim(coalesce(p_shortfall_note, '')), '');
+begin
+ if p_owner_id is null or p_year_id is null or jsonb_typeof(p_evaluation) is distinct from 'object' then
+ raise exception 'cme_invalid_request';
+ end if;
+ if v_note is not null and char_length(v_note) > 2000 then raise exception 'cme_invalid_request'; end if;
+ perform pg_advisory_xact_lock(hashtextextended(p_owner_id::text, 23092026));
+ select * into v_year from public.cme_years where owner_id=p_owner_id and id=p_year_id for update;
+ if not found then raise exception 'cme_year_not_confirmed'; end if;
+ if v_year.closed_at is not null then raise exception 'cme_year_closed'; end if;
+
+ select count(*), coalesce(sum(a.hours), 0) into v_count, v_total
+ from public.cme_entries e
+ left join lateral (
+ select sum(hours) as hours from public.cme_allocations where owner_id=p_owner_id and entry_id=e.id
+ ) a on true
+ where e.owner_id=p_owner_id and e.year_id=p_year_id and e.archived_at is null;
+ if round(v_total, 2) is distinct from round((p_evaluation->>'totalHours')::numeric, 2)
+ or v_count is distinct from (p_evaluation->>'entryCount')::integer then
+ raise exception 'cme_close_conflict';
+ end if;
+
+ v_record := jsonb_build_object(
+ 'year', v_year.year,
+ 'totalHours', v_year.total_hours,
+ 'confirmedOn', v_year.confirmed_on,
+ 'confirmedSource', v_year.confirmed_source,
+ 'requirements', (
+ select coalesce(jsonb_agg(jsonb_build_object(
+ 'id', r.id, 'label', r.label, 'source', r.source, 'spec', r.spec, 'completedOn', r.completed_on
+ ) order by r.sort_order, r.id), '[]'::jsonb)
+ from public.cme_requirements r where r.owner_id=p_owner_id and r.year_id=p_year_id
+ ),
+ 'entries', (
+ select coalesce(jsonb_agg(jsonb_build_object(
+ 'id', e.id, 'date', e.activity_date, 'title', e.title, 'reflection', e.reflection,
+ 'costCents', e.cost_cents, 'routineId', e.routine_id, 'documentId', e.document_id,
+ 'sourceUrl', e.source_url, 'buckets', to_jsonb(e.buckets),
+ 'formalPeerReviewHours', e.formal_peer_review_hours,
+ 'allocations', (
+ select coalesce(jsonb_agg(jsonb_build_object('category', a.category, 'hours', a.hours) order by a.category), '[]'::jsonb)
+ from public.cme_allocations a where a.owner_id=p_owner_id and a.entry_id=e.id
+ )
+ ) order by e.activity_date, e.id), '[]'::jsonb)
+ from public.cme_entries e where e.owner_id=p_owner_id and e.year_id=p_year_id and e.archived_at is null
+ )
+ );
+
+ -- Compare the complete inputs used by evaluateYear(), not only the lossy total/count.
+ -- The same year lock is taken by every requirement, activity and allocation writer.
+ v_inputs := jsonb_build_object(
+ 'year', v_record->'year', 'totalHours', v_record->'totalHours',
+ 'confirmedOn', v_record->'confirmedOn', 'confirmedSource', v_record->'confirmedSource',
+ 'requirements', (select coalesce(jsonb_agg(r order by r->>'id'), '[]'::jsonb)
+ from jsonb_array_elements(v_record->'requirements') r),
+ 'entries', (select coalesce(jsonb_agg(jsonb_build_object(
+ 'id', e->'id', 'buckets', e->'buckets',
+ 'formalPeerReviewHours', e->'formalPeerReviewHours', 'allocations', e->'allocations'
+ ) order by e->>'id'), '[]'::jsonb) from jsonb_array_elements(v_record->'entries') e)
+ );
+ if p_evaluation->'inputs' is distinct from v_inputs then
+ raise exception 'cme_close_conflict';
+ end if;
+
+ insert into public.cme_year_snapshots(owner_id, year_id, shortfall_note, record, evaluation, total_hours, target_hours)
+ values (p_owner_id, p_year_id, v_note, v_record, p_evaluation, round(v_total, 2), v_year.total_hours)
+ returning * into v_snapshot;
+ update public.cme_years set closed_at = v_snapshot.closed_at, shortfall_note = v_note
+ where owner_id=p_owner_id and id=p_year_id;
+ return to_jsonb(v_snapshot);
+end $$;
+
+-- New app instances must refuse old schemas rather than letting the legacy RPC ignore inputs.
+-- Old app instances on the new schema also fail closed in cme_close_year without inputs.
+create function public.cme_close_year_v2(p_owner_id uuid, p_year_id uuid, p_evaluation jsonb, p_shortfall_note text default null)
+returns jsonb language sql security invoker set search_path = public, pg_catalog, pg_temp as $$
+ select public.cme_close_year(p_owner_id, p_year_id, p_evaluation, p_shortfall_note)
+$$;
+alter function public.cme_close_year_v2(uuid, uuid, jsonb, text) owner to postgres;
+revoke all on function public.cme_close_year_v2(uuid, uuid, jsonb, text) from public, anon, authenticated;
+grant execute on function public.cme_close_year_v2(uuid, uuid, jsonb, text) to service_role;
diff --git a/supabase/migrations/20261105144248_audit_hospital_lifecycle.sql b/supabase/migrations/20261105144248_audit_hospital_lifecycle.sql
new file mode 100644
index 0000000000..f083faff12
--- /dev/null
+++ b/supabase/migrations/20261105144248_audit_hospital_lifecycle.sql
@@ -0,0 +1,39 @@
+-- Keep linked teams in live hospitals. Both directions share the hospital-row lock,
+-- including direct service-role writes and the existing atomic move/revocation RPC.
+set local lock_timeout = '5s';
+set local statement_timeout = '30s';
+
+create function public.work_guard_live_hospital_team() returns trigger
+language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$
+begin
+ perform 1 from public.work_hospitals
+ where id = new.hospital_id and archived_at is null for share;
+ if not found then raise exception 'work_hospital_unavailable'; end if;
+ return new;
+end $$;
+
+create trigger work_hospital_team_live_guard
+ before insert or update of hospital_id on public.work_hospital_teams
+ for each row execute function public.work_guard_live_hospital_team();
+
+create function public.work_guard_hospital_archive() returns trigger
+language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$
+begin
+ -- UPDATE holds the conflicting hospital-row lock before this trigger runs. Re-read
+ -- links here after any wait; an earlier application preflight is not authoritative.
+ if old.archived_at is null and new.archived_at is not null and exists (
+ select 1 from public.work_hospital_teams where hospital_id = old.id
+ ) then raise exception 'work_hospital_has_teams'; end if;
+ return new;
+end $$;
+
+create trigger work_hospital_archive_guard
+ before update of archived_at on public.work_hospitals
+ for each row execute function public.work_guard_hospital_archive();
+
+alter function public.work_guard_live_hospital_team() owner to postgres;
+alter function public.work_guard_hospital_archive() owner to postgres;
+revoke all on function public.work_guard_live_hospital_team(), public.work_guard_hospital_archive()
+ from public, anon, authenticated;
+grant execute on function public.work_guard_live_hospital_team(), public.work_guard_hospital_archive()
+ to service_role;
diff --git a/supabase/migrations/20261105144251_audit_atomic_document_maintenance.sql b/supabase/migrations/20261105144251_audit_atomic_document_maintenance.sql
new file mode 100644
index 0000000000..997f3cf38b
--- /dev/null
+++ b/supabase/migrations/20261105144251_audit_atomic_document_maintenance.sql
@@ -0,0 +1,681 @@
+-- Draft only. Applying/merging this migration requires the owner's own database approval.
+-- Existing source identities only; no backend admission, scanner or supplier authority is minted.
+set local lock_timeout = '5s';
+set local statement_timeout = '60s';
+
+create function public.enqueue_import_document(
+ p_document_id uuid, p_owner_id uuid, p_content_hash text, p_batch_id uuid,
+ p_force boolean, p_max_attempts integer
+) returns jsonb language plpgsql security definer set search_path = '' as $$
+declare d public.documents; j public.ingestion_jobs;
+begin
+ if auth.role() is distinct from 'service_role' then raise exception 'service_role_required' using errcode='42501'; end if;
+ if p_owner_id is null or p_content_hash is null or p_content_hash = '' or p_force is null
+ or p_max_attempts is null or p_max_attempts not between 1 and 100 then
+ raise exception 'invalid_import_queue_request' using errcode='22023';
+ end if;
+ select * into d from public.documents where id=p_document_id for update;
+ if not found or d.owner_id is distinct from p_owner_id or d.content_hash is distinct from p_content_hash then
+ raise exception 'import_document_identity_conflict' using errcode='40001';
+ end if;
+ perform 1 from public.import_batches where id=p_batch_id and owner_id=p_owner_id for share;
+ if not found then raise exception 'import_batch_owner_conflict' using errcode='40001'; end if;
+ select * into j from public.ingestion_jobs where document_id=d.id and status in ('pending','processing') order by id limit 1 for update;
+ if found then return jsonb_build_object('outcome','already_active','job_id',j.id); end if;
+ perform 1 from public.indexing_v3_agent_jobs where document_id=d.id and status in ('pending','processing') for update;
+ if found then raise exception 'maintenance_active_agent_job' using errcode='40001'; end if;
+ if d.status='indexed' and not p_force then return jsonb_build_object('outcome','skipped'); end if;
+ -- Generation A and its counters remain usable until a fenced worker promotes B.
+ insert into public.ingestion_jobs(document_id,batch_id,status,stage,progress,max_attempts)
+ values(d.id,p_batch_id,'pending','queued',0,p_max_attempts) returning * into j;
+ update public.documents set status=case when d.status='indexed' then d.status else 'queued' end,
+ error_message=null where id=d.id;
+ return jsonb_build_object('outcome','enqueued','job_id',j.id);
+end $$;
+alter function public.enqueue_import_document(uuid,uuid,text,uuid,boolean,integer) owner to postgres;
+revoke all on function public.enqueue_import_document(uuid,uuid,text,uuid,boolean,integer) from public,anon,authenticated,service_role;
+grant execute on function public.enqueue_import_document(uuid,uuid,text,uuid,boolean,integer) to service_role;
+
+-- One transaction replaces only the current public projection. Generation-retained evidence is
+-- never a maintenance target. Managed documents continue through their existing fenced worker.
+create function public.replace_document_maintenance_artifacts(
+ p_document_id uuid, p_owner_id uuid, p_expected_updated_at timestamptz,
+ p_expected_generation_id uuid, p_operation text, p_payload jsonb
+) returns jsonb language plpgsql security definer set search_path = '' as $$
+declare
+ d public.documents; item jsonb; patch jsonb; image_ids uuid[]; source_ids uuid[];
+ unit public.document_index_units; field public.document_embedding_fields; image public.document_images; summary_row public.document_summaries;
+ visual_types text[] := array['visual_summary','flowchart_step','diagram_decision','risk_matrix_cell','medication_chart_row','chart_finding','visual_askable_question','table_threshold'];
+begin
+ if auth.role() is distinct from 'service_role' then raise exception 'service_role_required' using errcode='42501'; end if;
+ if p_expected_updated_at is null or p_operation is null or p_operation not in ('labels','enrichment','visual','images','stamp','embedding-fields','registry')
+ or jsonb_typeof(p_payload) is distinct from 'object' then raise exception 'invalid_maintenance_request' using errcode='22023'; end if;
+ select * into d from public.documents where id=p_document_id for update;
+ if not found or d.owner_id is distinct from p_owner_id or d.updated_at is distinct from p_expected_updated_at
+ or d.index_generation_id is distinct from p_expected_generation_id or d.status is distinct from 'indexed' then
+ raise exception 'maintenance_snapshot_conflict' using errcode='40001';
+ end if;
+ if d.metadata ?| array['admission_event_id','public_source_version_id','source_catalogue_key','public_source_activation_event_id'] or d.metadata->>'corpus_scope'='australian_public' then raise exception 'managed_evidence_requires_fenced_worker' using errcode='42501'; end if;
+ perform 1 from public.ingestion_jobs where document_id=d.id and status in ('pending','processing') for update;
+ if found then raise exception 'maintenance_active_ingestion_job' using errcode='40001'; end if;
+ perform 1 from public.indexing_v3_agent_jobs where document_id=d.id and status in ('pending','processing') for update;
+ if found then raise exception 'maintenance_active_agent_job' using errcode='40001'; end if;
+ patch:=coalesce(p_payload->'metadata_patch','{}'::jsonb);
+ if p_operation='registry' then
+ if d.file_type is distinct from 'application/vnd.clinical-kb.registry+json'
+ or patch->>'source_kind' is distinct from 'registry_record'
+ or (patch->>'registry_record_kind' in ('service','form','medication','differential')) is not true
+ or d.storage_path is distinct from 'registry://'||(patch->>'registry_record_kind')||'/'||(patch->>'registry_record_id')
+ or d.source_path is distinct from 'registry://'||(patch->>'registry_record_kind')||'/'||(patch->>'registry_record_slug')
+ or jsonb_typeof(patch) is distinct from 'object' or exists(select 1 from jsonb_object_keys(patch) k where k<>all(array[
+ 'source_kind','registry_record_kind','registry_record_subkind','registry_record_id','registry_record_slug','source_title',
+ 'document_status','clinical_validation_status','clinical_validation_evidence','source_role','extraction_quality','publisher','jurisdiction'])) then
+ raise exception 'invalid_registry_maintenance_scope' using errcode='22023'; end if;
+ else
+ if jsonb_typeof(patch) is distinct from 'object' or exists(select 1 from jsonb_object_keys(patch) k
+ where k<>all(array['rag_enrichment_version','rag_indexing_version','rag_memory_version','rag_enrichment_updated_at',
+ 'generated_label_count','organization_profile','organization_profile_version','coverage_profile',
+ 'enrichment_prompt_strategy','enrichment_prompt_chunk_ids','version_stamped_at',
+ 'searchable_image_count','skipped_image_count','image_enriched_at'])) then
+ raise exception 'invalid_maintenance_metadata_keys' using errcode='22023';
+ end if;
+ end if;
+
+ if p_operation in ('labels','enrichment') then
+ if jsonb_typeof(p_payload->'labels') is distinct from 'array' or jsonb_array_length(p_payload->'labels')>5000 then
+ raise exception 'invalid_maintenance_labels' using errcode='22023'; end if;
+ for item in select value from jsonb_array_elements(p_payload->'labels') loop
+ if jsonb_typeof(item) is distinct from 'object' or nullif(btrim(item->>'label'),'') is null
+ or jsonb_typeof(item->'metadata') is distinct from 'object' then raise exception 'invalid_maintenance_label' using errcode='22023'; end if;
+ end loop;
+ -- The delete and all replacement inserts share this transaction; insert failure restores A.
+ delete from public.document_labels where document_id=d.id and source='generated';
+ insert into public.document_labels(document_id,owner_id,label,label_type,source,confidence,metadata)
+ select d.id,d.owner_id,r.label,r.label_type,'generated',r.confidence,r.metadata
+ from jsonb_to_recordset(p_payload->'labels') r(label text,label_type text,confidence real,metadata jsonb);
+ end if;
+ if p_operation='registry' then
+ if jsonb_typeof(p_payload->'label_ids_to_delete') is distinct from 'array' or jsonb_typeof(p_payload->'labels_to_upsert') is distinct from 'array'
+ or jsonb_array_length(p_payload->'label_ids_to_delete')>5000 or jsonb_array_length(p_payload->'labels_to_upsert')>5000 then
+ raise exception 'invalid_registry_label_population' using errcode='22023'; end if;
+ select coalesce(array_agg(value::uuid),'{}'::uuid[]) into source_ids from jsonb_array_elements_text(p_payload->'label_ids_to_delete');
+ if exists(select 1 from unnest(source_ids) id where not exists(select 1 from public.document_labels l where l.id=id
+ and l.document_id=d.id and l.owner_id is not distinct from d.owner_id and l.source='generated' and l.label_type in ('site','document_intent'))) then
+ raise exception 'registry_label_snapshot_conflict' using errcode='40001'; end if;
+ for item in select value from jsonb_array_elements(p_payload->'labels_to_upsert') loop
+ if item->>'label_type' is distinct from 'document_intent' or item->>'label' is distinct from
+ (case patch->>'registry_record_kind' when 'service' then 'operational-process' when 'form' then 'documentation-requirement'
+ when 'medication' then 'medication-instruction' when 'differential' then 'decision-support' end)
+ or jsonb_typeof(item->'metadata') is distinct from 'object' then
+ raise exception 'invalid_registry_intent_label' using errcode='22023'; end if;
+ end loop;
+ delete from public.document_labels where document_id=d.id and owner_id is not distinct from d.owner_id
+ and source='generated' and id=any(source_ids);
+ insert into public.document_labels(document_id,owner_id,label,label_type,source,confidence,metadata)
+ select d.id,d.owner_id,r.label,'document_intent','generated',r.confidence,r.metadata
+ from jsonb_to_recordset(p_payload->'labels_to_upsert') r(label text,confidence real,metadata jsonb)
+ on conflict(document_id,label_type,label,source) do update set confidence=excluded.confidence,metadata=excluded.metadata;
+ end if;
+
+ if p_operation='enrichment' then
+ item:=p_payload->'summary';
+ if jsonb_typeof(item) is distinct from 'object' or nullif(btrim(item->>'summary'),'') is null
+ or jsonb_typeof(item->'metadata') is distinct from 'object'
+ or jsonb_typeof(item->'source_chunk_ids') is distinct from 'array'
+ or jsonb_typeof(item->'source_image_ids') is distinct from 'array' then
+ raise exception 'invalid_maintenance_summary' using errcode='22023'; end if;
+ select coalesce(array_agg(value::uuid),'{}'::uuid[]) into source_ids from jsonb_array_elements_text(item->'source_chunk_ids');
+ if exists(select 1 from unnest(source_ids) id where not exists(select 1 from public.document_chunks c
+ where c.id=id and c.document_id=d.id and c.index_generation_id is not distinct from p_expected_generation_id)) then
+ raise exception 'maintenance_source_generation_conflict' using errcode='40001'; end if;
+ select coalesce(array_agg(value::uuid),'{}'::uuid[]) into image_ids from jsonb_array_elements_text(item->'source_image_ids');
+ if exists(select 1 from unnest(image_ids) id where not exists(select 1 from public.document_images i
+ where i.id=id and i.document_id=d.id and i.index_generation_id is not distinct from p_expected_generation_id)) then
+ raise exception 'maintenance_source_generation_conflict' using errcode='40001'; end if;
+ insert into public.document_summaries(document_id,owner_id,summary,clinical_specifics,source_chunk_ids,source_image_ids,model,metadata,generated_at,updated_at)
+ values(d.id,d.owner_id,item->>'summary',coalesce(item->'clinical_specifics','{}'::jsonb),source_ids,image_ids,item->>'model',item->'metadata',(item->>'generated_at')::timestamptz,(item->>'updated_at')::timestamptz)
+ on conflict(document_id) do update set owner_id=excluded.owner_id,summary=excluded.summary,clinical_specifics=excluded.clinical_specifics,
+ source_chunk_ids=excluded.source_chunk_ids,source_image_ids=excluded.source_image_ids,model=excluded.model,metadata=excluded.metadata,
+ generated_at=excluded.generated_at,updated_at=excluded.updated_at returning * into summary_row;
+ end if;
+
+ if p_operation='visual' then
+ if jsonb_typeof(p_payload->'image_ids') is distinct from 'array' or jsonb_typeof(p_payload->'units') is distinct from 'array'
+ or jsonb_array_length(p_payload->'image_ids')>1000 or jsonb_array_length(p_payload->'units')>10000 then
+ raise exception 'invalid_maintenance_visual_population' using errcode='22023'; end if;
+ select coalesce(array_agg(value::uuid),'{}'::uuid[]) into image_ids from jsonb_array_elements_text(p_payload->'image_ids');
+ if exists(select 1 from unnest(image_ids) id where not exists(select 1 from public.document_images i
+ where i.id=id and i.document_id=d.id and i.index_generation_id is not distinct from p_expected_generation_id)) then
+ raise exception 'maintenance_source_generation_conflict' using errcode='40001'; end if;
+ -- Validate the complete packet before replacing anything. Row/table constraints are still authoritative.
+ for item in select value from jsonb_array_elements(p_payload->'units') loop
+ unit:=jsonb_populate_record(null::public.document_index_units,item);
+ if unit.document_id is distinct from d.id or unit.owner_id is distinct from d.owner_id
+ or unit.index_generation_id is distinct from p_expected_generation_id or unit.source_image_id is null
+ or not(unit.source_image_id=any(image_ids)) or unit.unit_type is null or not(unit.unit_type=any(visual_types)) then
+ raise exception 'invalid_maintenance_visual_identity' using errcode='22023'; end if;
+ if unit.source_chunk_id is not null and not exists(select 1 from public.document_chunks c where c.id=unit.source_chunk_id
+ and c.document_id=d.id and c.index_generation_id is not distinct from p_expected_generation_id) then
+ raise exception 'maintenance_source_generation_conflict' using errcode='40001'; end if;
+ end loop;
+ delete from public.document_index_units where document_id=d.id and index_generation_id is not distinct from p_expected_generation_id
+ and source_image_id=any(image_ids) and unit_type=any(visual_types);
+ for item in select value from jsonb_array_elements(p_payload->'units') loop
+ unit:=jsonb_populate_record(null::public.document_index_units,item);
+ insert into public.document_index_units(owner_id,document_id,unit_type,source_chunk_id,source_image_id,page_start,page_end,
+ heading_path,title,content,normalized_terms,source_span,quality_score,extraction_mode,index_generation_id,embedding,metadata)
+ values(d.owner_id,d.id,unit.unit_type,unit.source_chunk_id,unit.source_image_id,unit.page_start,unit.page_end,
+ coalesce(unit.heading_path,'{}'),unit.title,unit.content,coalesce(unit.normalized_terms,'{}'),unit.source_span,
+ coalesce(unit.quality_score,0.7),coalesce(unit.extraction_mode,'deterministic'),p_expected_generation_id,unit.embedding,coalesce(unit.metadata,'{}'));
+ end loop;
+ end if;
+
+ if p_operation in ('visual','images') then
+ if jsonb_typeof(p_payload->'image_patches') is distinct from 'array' or jsonb_array_length(p_payload->'image_patches')>1000 then
+ raise exception 'invalid_maintenance_image_population' using errcode='22023'; end if;
+ for item in select value from jsonb_array_elements(p_payload->'image_patches') loop
+ select * into image from public.document_images i where i.id=(item->>'id')::uuid and i.document_id=d.id
+ and i.index_generation_id is not distinct from p_expected_generation_id for update;
+ if not found or (p_operation='visual' and not(image.id=any(image_ids))) then
+ raise exception 'maintenance_source_generation_conflict' using errcode='40001'; end if;
+ if jsonb_typeof(item) is distinct from 'object' or exists(select 1 from jsonb_object_keys(item) k where k<>all(array[
+ 'id','metadata','caption','image_type','searchable','clinical_relevance_score','skip_reason','image_hash','perceptual_hash','labels']))
+ or (item ? 'metadata' and jsonb_typeof(item->'metadata') is distinct from 'object') then
+ raise exception 'invalid_maintenance_image_patch' using errcode='22023'; end if;
+ image:=jsonb_populate_record(image,item-'id'-'metadata');
+ update public.document_images set caption=image.caption,image_type=image.image_type,searchable=image.searchable,
+ clinical_relevance_score=image.clinical_relevance_score,skip_reason=image.skip_reason,image_hash=image.image_hash,
+ perceptual_hash=image.perceptual_hash,labels=image.labels,metadata=coalesce(metadata,'{}')||coalesce(item->'metadata','{}')
+ where id=image.id;
+ end loop;
+ end if;
+ if p_operation='embedding-fields' then
+ if jsonb_typeof(p_payload->'fields') is distinct from 'array' or jsonb_array_length(p_payload->'fields')>1000 then
+ raise exception 'invalid_maintenance_embedding_fields' using errcode='22023'; end if;
+ for item in select value from jsonb_array_elements(p_payload->'fields') loop
+ field:=jsonb_populate_record(null::public.document_embedding_fields,item);
+ if field.document_id is distinct from d.id or field.owner_id is distinct from d.owner_id or field.source_chunk_id is null
+ or field.field_type not in ('document_title','document_summary') or not exists(select 1 from public.document_chunks c
+ where c.id=field.source_chunk_id and c.document_id=d.id and c.index_generation_id is not distinct from p_expected_generation_id) then
+ raise exception 'maintenance_source_generation_conflict' using errcode='40001'; end if;
+ insert into public.document_embedding_fields(owner_id,document_id,source_chunk_id,field_type,content,content_hash,embedding,index_generation_id,metadata)
+ values(d.owner_id,d.id,field.source_chunk_id,field.field_type,field.content,field.content_hash,field.embedding,p_expected_generation_id,
+ coalesce(field.metadata,'{}')||jsonb_build_object('index_generation_id',p_expected_generation_id));
+ end loop;
+ end if;
+
+ if p_operation='stamp' then
+ update public.document_summaries set metadata=coalesce(metadata,'{}')||patch where document_id=d.id;
+ update public.document_labels set metadata=coalesce(metadata,'{}')||patch where document_id=d.id and source='generated';
+ end if;
+ if p_operation='images' and (jsonb_typeof(p_payload->'image_count') is distinct from 'number'
+ or (p_payload->>'image_count')::numeric < 0 or (p_payload->>'image_count')::numeric<>trunc((p_payload->>'image_count')::numeric)) then
+ raise exception 'invalid_maintenance_image_count' using errcode='22023'; end if;
+ update public.documents set metadata=coalesce(metadata,'{}')||patch,
+ image_count=case when p_operation='images' then (p_payload->>'image_count')::integer else image_count end
+ where id=d.id returning * into d;
+ return jsonb_build_object('updated_at',d.updated_at,'metadata',d.metadata,'summary',to_jsonb(summary_row));
+end $$;
+alter function public.replace_document_maintenance_artifacts(uuid,uuid,timestamptz,uuid,text,jsonb) owner to postgres;
+revoke all on function public.replace_document_maintenance_artifacts(uuid,uuid,timestamptz,uuid,text,jsonb) from public,anon,authenticated,service_role;
+grant execute on function public.replace_document_maintenance_artifacts(uuid,uuid,timestamptz,uuid,text,jsonb) to service_role;
+
+-- Snapshot-fenced legacy local producer commit. Current-generation cleanup only; retained
+-- generations remain available to rollback. The fenced worker overload is unchanged.
+create function public.commit_document_maintenance_deep_memory(
+ p_document_id uuid,
+ p_owner_id uuid,
+ p_expected_updated_at timestamptz,
+ p_expected_generation_id uuid,
+ p_chunk_patches jsonb,
+ p_aliases jsonb,
+ p_producer text,
+ p_artifact_generation_id uuid,
+ p_rag_memory_version text,
+ p_document_intelligence_version text,
+ p_section_count integer,
+ p_memory_card_count integer,
+ p_index_unit_counts_by_type jsonb,
+ p_repaired_anchor_count integer default 0
+)
+returns jsonb
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ v_document_metadata jsonb;
+ v_document_updated_at timestamptz;
+ v_chunk_patch jsonb;
+ v_chunk_count integer;
+ v_live_document public.documents;
+ v_document_owner_id uuid;
+ v_committed_index_generation uuid;
+ v_total_section_count integer;
+ v_total_memory_card_count integer;
+ v_total_index_unit_count integer;
+ v_section_count integer;
+ v_memory_card_count integer;
+ v_index_unit_count integer;
+ v_expected_index_unit_count integer;
+ v_index_unit_counts_by_type jsonb;
+begin
+ if auth.role() is distinct from 'service_role' then raise exception 'service_role_required' using errcode='42501'; end if;
+ if p_producer is distinct from 'local-worker' then raise exception 'maintenance_local_producer_required' using errcode='42501'; end if;
+ if nullif(btrim(p_producer), '') is null
+ or nullif(btrim(p_rag_memory_version), '') is null
+ or nullif(btrim(p_document_intelligence_version), '') is null
+ then
+ raise exception 'Deep-memory producer must be non-empty.' using errcode = '22023';
+ end if;
+ if p_artifact_generation_id is null then
+ raise exception 'Deep-memory artifact generation must be set.' using errcode = '22023';
+ end if;
+ if p_section_count is null or p_memory_card_count is null or p_repaired_anchor_count is null
+ or p_section_count < 0 or p_memory_card_count < 0 or p_repaired_anchor_count < 0
+ then
+ raise exception 'Deep-memory counts cannot be negative.' using errcode = '22023';
+ end if;
+ if p_index_unit_counts_by_type is null or jsonb_typeof(p_index_unit_counts_by_type) <> 'object' then
+ raise exception 'Deep-memory index-unit counts must be an object.' using errcode = '22023';
+ end if;
+ if exists (
+ select 1
+ from jsonb_each(p_index_unit_counts_by_type) item
+ where jsonb_typeof(item.value) <> 'number'
+ or (item.value #>> '{}')::numeric < 0
+ or trunc((item.value #>> '{}')::numeric) <> (item.value #>> '{}')::numeric
+ or (item.value #>> '{}')::numeric > 2147483647
+ ) then
+ raise exception 'Deep-memory index-unit counts must be nonnegative integers.' using errcode = '22023';
+ end if;
+
+ select coalesce(d.metadata, '{}'::jsonb), d.owner_id, d.updated_at
+ into v_document_metadata, v_document_owner_id, v_document_updated_at
+ from public.documents d
+ where d.id = p_document_id
+ for update;
+
+ if not found then
+ raise exception 'Document % does not exist.', p_document_id using errcode = 'P0002';
+ end if;
+
+ perform 1 from public.documents where id=p_document_id and status='indexed';
+ if not found then raise exception 'maintenance_snapshot_conflict' using errcode='40001'; end if;
+ if p_expected_updated_at is null or v_document_updated_at is distinct from p_expected_updated_at
+ or v_document_owner_id is distinct from p_owner_id
+ or (nullif(v_document_metadata->>'index_generation_id',''))::uuid is distinct from p_expected_generation_id then
+ raise exception 'maintenance_snapshot_conflict' using errcode='40001';
+ end if;
+ if v_document_metadata ?| array['admission_event_id','public_source_version_id','source_catalogue_key','public_source_activation_event_id'] or v_document_metadata->>'corpus_scope'='australian_public' then raise exception 'managed_evidence_requires_fenced_worker' using errcode='42501'; end if;
+ perform 1 from public.ingestion_jobs where document_id=p_document_id and status in ('pending','processing') for update;
+ if found then raise exception 'maintenance_active_ingestion_job' using errcode='40001'; end if;
+ perform 1 from public.indexing_v3_agent_jobs where document_id=p_document_id and status in ('pending','processing') for update;
+ if found then raise exception 'maintenance_active_agent_job' using errcode='40001'; end if;
+ -- NULL legacy lineage cannot be assigned to a retained generation without evidence.
+ -- Refuse instead of deleting potential rollback rows or publishing duplicate visible rows.
+ if exists(select 1 from public.document_sections where document_id=p_document_id and index_generation_id is null)
+ or exists(select 1 from public.document_memory_cards where document_id=p_document_id and index_generation_id is null)
+ or exists(select 1 from public.document_index_units where document_id=p_document_id and index_generation_id is null) then
+ raise exception 'legacy_generation_lineage_requires_fenced_reindex' using errcode='23514';
+ end if;
+ if jsonb_typeof(p_chunk_patches) is distinct from 'array' then raise exception 'invalid_maintenance_chunk_patches' using errcode='22023'; end if;
+ for v_chunk_patch in select value from jsonb_array_elements(p_chunk_patches) loop
+ if jsonb_typeof(v_chunk_patch) is distinct from 'object' or jsonb_typeof(v_chunk_patch->'metadata') is distinct from 'object'
+ or exists(select 1 from jsonb_object_keys(v_chunk_patch->'metadata') k where k<>all(array[
+ 'anchor_id','anchor_repaired_by','rag_indexing_version','rag_memory_version','rag_memory_updated_at','document_intelligence_version'])) then
+ raise exception 'invalid_maintenance_chunk_patch' using errcode='22023'; end if;
+ update public.document_chunks set
+ anchor_id=coalesce(anchor_id,v_chunk_patch->>'anchor_id'),
+ metadata=coalesce(metadata,'{}')||(v_chunk_patch->'metadata')
+ where id=(v_chunk_patch->>'id')::uuid and document_id=p_document_id
+ and index_generation_id is not distinct from p_expected_generation_id;
+ get diagnostics v_chunk_count=row_count;
+ if v_chunk_count<>1 then raise exception 'maintenance_source_generation_conflict' using errcode='40001'; end if;
+ end loop;
+
+ begin
+ v_committed_index_generation := nullif(v_document_metadata->>'index_generation_id', '')::uuid;
+ exception when invalid_text_representation then
+ raise exception 'Document % has an invalid committed index generation.', p_document_id using errcode = '22023';
+ end;
+ if v_committed_index_generation is null then
+ raise exception 'Document % has no committed index generation.', p_document_id using errcode = '23514';
+ end if;
+ if v_committed_index_generation = p_artifact_generation_id then
+ raise exception 'Staged deep-memory generation must differ from the committed index generation.' using errcode = '23514';
+ end if;
+
+ -- A generation UUID is a single-producer staging boundary. Reject collisions
+ -- rather than interpreting another producer's rows as part of this commit.
+ if exists (
+ select 1 from public.document_sections
+ where document_id = p_document_id
+ and artifact_generation_id = p_artifact_generation_id
+ and producer is distinct from p_producer
+ ) or exists (
+ select 1 from public.document_memory_cards
+ where document_id = p_document_id
+ and artifact_generation_id = p_artifact_generation_id
+ and producer is distinct from p_producer
+ ) or exists (
+ select 1 from public.document_index_units
+ where document_id = p_document_id
+ and artifact_generation_id = p_artifact_generation_id
+ and producer is distinct from p_producer
+ ) then
+ raise exception 'Deep-memory artifact generation belongs to another producer.' using errcode = '23514';
+ end if;
+
+ -- Re-check producer evidence inside the transaction. Legacy NULL-generation
+ -- local-worker rows predate explicit producer metadata and are the only
+ -- unlabelled shape eligible for producer-scoped replacement.
+ if exists (
+ select 1 from public.document_sections
+ where document_id = p_document_id
+ and (
+ (producer is not null and nullif(metadata->>'generated_by', '') is distinct from producer)
+ or (producer is null and nullif(metadata->>'generated_by', '') is not null and metadata->>'generated_by' <> p_producer)
+ or (producer is null and artifact_generation_id is not null)
+ or (
+ producer is null
+ and nullif(metadata->>'generated_by', '') is null
+ and not (
+ p_producer = 'local-worker'
+ and artifact_generation_id is null
+ and metadata->>'rag_indexing_version' in ('rag-deep-memory-v1', 'rag-deep-memory-v2')
+ )
+ )
+ )
+ ) or exists (
+ select 1 from public.document_memory_cards
+ where document_id = p_document_id
+ and (
+ (producer is not null and nullif(metadata->>'generated_by', '') is distinct from producer)
+ or (producer is null and nullif(metadata->>'generated_by', '') is not null and metadata->>'generated_by' <> p_producer)
+ or (producer is null and artifact_generation_id is not null)
+ or (
+ producer is null
+ and nullif(metadata->>'generated_by', '') is null
+ and not (p_producer = 'local-worker' and artifact_generation_id is null)
+ )
+ )
+ ) or exists (
+ select 1 from public.document_index_units
+ where document_id = p_document_id
+ and (
+ (producer is not null and nullif(metadata->>'generated_by', '') is distinct from producer)
+ or (producer is null and nullif(metadata->>'generated_by', '') is not null and metadata->>'generated_by' <> p_producer)
+ or (producer is null and artifact_generation_id is not null)
+ or (
+ producer is null
+ and nullif(metadata->>'generated_by', '') is null
+ and not (p_producer = 'local-worker' and artifact_generation_id is null)
+ )
+ )
+ ) then
+ raise exception 'Deep-memory artifact producer evidence is contradictory or ambiguous.' using errcode = '23514';
+ end if;
+
+ select count(*) into v_total_section_count
+ from public.document_sections
+ where document_id = p_document_id
+ and artifact_generation_id = p_artifact_generation_id;
+
+ select count(*) into v_total_memory_card_count
+ from public.document_memory_cards
+ where document_id = p_document_id
+ and artifact_generation_id = p_artifact_generation_id;
+
+ select count(*) into v_total_index_unit_count
+ from public.document_index_units
+ where document_id = p_document_id
+ and artifact_generation_id = p_artifact_generation_id;
+
+ select count(*) into v_section_count
+ from public.document_sections
+ where document_id = p_document_id
+ and producer = p_producer
+ and artifact_generation_id = p_artifact_generation_id
+ and index_generation_id = p_artifact_generation_id
+ and owner_id is not distinct from v_document_owner_id
+ and metadata->>'generated_by' = p_producer
+ and metadata->>'artifact_generation_id' = p_artifact_generation_id::text
+ and metadata->>'index_generation_id' = p_artifact_generation_id::text;
+
+ select count(*) into v_memory_card_count
+ from public.document_memory_cards
+ where document_id = p_document_id
+ and producer = p_producer
+ and artifact_generation_id = p_artifact_generation_id
+ and index_generation_id = p_artifact_generation_id
+ and owner_id is not distinct from v_document_owner_id
+ and metadata->>'generated_by' = p_producer
+ and metadata->>'artifact_generation_id' = p_artifact_generation_id::text
+ and metadata->>'index_generation_id' = p_artifact_generation_id::text;
+
+ select coalesce(sum(unit_count), 0)::integer, coalesce(jsonb_object_agg(unit_type, unit_count), '{}'::jsonb)
+ into v_index_unit_count, v_index_unit_counts_by_type
+ from (
+ select unit_type, count(*)::integer as unit_count
+ from public.document_index_units
+ where document_id = p_document_id
+ and producer = p_producer
+ and artifact_generation_id = p_artifact_generation_id
+ and index_generation_id = p_artifact_generation_id
+ and owner_id is not distinct from v_document_owner_id
+ and metadata->>'generated_by' = p_producer
+ and metadata->>'artifact_generation_id' = p_artifact_generation_id::text
+ and metadata->>'index_generation_id' = p_artifact_generation_id::text
+ group by unit_type
+ ) staged_index_units;
+
+ select coalesce(sum(value::integer), 0)
+ into v_expected_index_unit_count
+ from jsonb_each_text(coalesce(p_index_unit_counts_by_type, '{}'::jsonb));
+
+ if v_total_section_count <> v_section_count
+ or v_total_memory_card_count <> v_memory_card_count
+ or v_total_index_unit_count <> coalesce(v_index_unit_count, 0)
+ or v_section_count <> p_section_count
+ or v_memory_card_count <> p_memory_card_count
+ or coalesce(v_index_unit_count, 0) <> v_expected_index_unit_count
+ or v_index_unit_counts_by_type <> coalesce(p_index_unit_counts_by_type, '{}'::jsonb)
+ then
+ raise exception 'Staged deep-memory artifact counts do not match the commit contract.' using errcode = '23514';
+ end if;
+
+ if exists (
+ select 1
+ from public.document_memory_cards card
+ left join public.document_sections section
+ on section.id = card.section_id
+ and section.document_id = p_document_id
+ and section.producer = p_producer
+ and section.artifact_generation_id = p_artifact_generation_id
+ where card.document_id = p_document_id
+ and card.producer = p_producer
+ and card.artifact_generation_id = p_artifact_generation_id
+ and card.section_id is not null
+ and section.id is null
+ ) then
+ raise exception 'Staged memory cards reference a section outside the staged generation.' using errcode = '23514';
+ end if;
+
+ -- Refuse to null another producer's section reference through ON DELETE SET
+ -- NULL. This keeps the "other producers untouched" guarantee literal.
+ if exists (
+ select 1
+ from public.document_memory_cards card
+ join public.document_sections section on section.id = card.section_id
+ where section.document_id = p_document_id
+ and section.artifact_generation_id is distinct from p_artifact_generation_id
+ and (
+ section.producer = p_producer
+ or (
+ section.producer is null
+ and (
+ section.metadata->>'generated_by' = p_producer
+ or (
+ p_producer = 'local-worker'
+ and nullif(section.metadata->>'generated_by', '') is null
+ and section.metadata->>'rag_indexing_version' in ('rag-deep-memory-v1', 'rag-deep-memory-v2')
+ )
+ )
+ )
+ )
+ and not (
+ (card.producer = p_producer and card.metadata->>'generated_by' = p_producer)
+ or (card.producer is null and card.metadata->>'generated_by' = p_producer)
+ or (
+ p_producer = 'local-worker'
+ and card.producer is null
+ and card.artifact_generation_id is null
+ and nullif(card.metadata->>'generated_by', '') is null
+ )
+ )
+ ) then
+ raise exception 'Another producer references an older section owned by this producer.' using errcode = '23514';
+ end if;
+
+ update public.document_sections
+ set
+ index_generation_id = v_committed_index_generation,
+ metadata = coalesce(metadata, '{}'::jsonb) || jsonb_build_object(
+ 'generated_by', p_producer,
+ 'artifact_generation_id', p_artifact_generation_id,
+ 'index_generation_id', v_committed_index_generation
+ ),
+ updated_at = now()
+ where document_id = p_document_id
+ and producer = p_producer
+ and artifact_generation_id = p_artifact_generation_id;
+
+ update public.document_memory_cards
+ set
+ index_generation_id = v_committed_index_generation,
+ metadata = coalesce(metadata, '{}'::jsonb) || jsonb_build_object(
+ 'generated_by', p_producer,
+ 'artifact_generation_id', p_artifact_generation_id,
+ 'index_generation_id', v_committed_index_generation
+ ),
+ updated_at = now()
+ where document_id = p_document_id
+ and producer = p_producer
+ and artifact_generation_id = p_artifact_generation_id;
+
+ update public.document_index_units
+ set
+ index_generation_id = v_committed_index_generation,
+ metadata = coalesce(metadata, '{}'::jsonb) || jsonb_build_object(
+ 'generated_by', p_producer,
+ 'artifact_generation_id', p_artifact_generation_id,
+ 'index_generation_id', v_committed_index_generation
+ ),
+ updated_at = now()
+ where document_id = p_document_id
+ and producer = p_producer
+ and artifact_generation_id = p_artifact_generation_id;
+
+ delete from public.document_memory_cards
+ where document_id = p_document_id
+ and index_generation_id is not distinct from p_expected_generation_id
+ and artifact_generation_id is distinct from p_artifact_generation_id
+ and (
+ (producer = p_producer and metadata->>'generated_by' = p_producer)
+ or (producer is null and metadata->>'generated_by' = p_producer)
+ or (
+ p_producer = 'local-worker'
+ and producer is null
+ and artifact_generation_id is null
+ and nullif(metadata->>'generated_by', '') is null
+ )
+ );
+
+ delete from public.document_index_units
+ where document_id = p_document_id
+ and index_generation_id is not distinct from p_expected_generation_id
+ and artifact_generation_id is distinct from p_artifact_generation_id
+ and (
+ (producer = p_producer and metadata->>'generated_by' = p_producer)
+ or (producer is null and metadata->>'generated_by' = p_producer)
+ or (
+ p_producer = 'local-worker'
+ and producer is null
+ and artifact_generation_id is null
+ and nullif(metadata->>'generated_by', '') is null
+ )
+ );
+
+ delete from public.document_sections
+ where document_id = p_document_id
+ and index_generation_id is not distinct from p_expected_generation_id
+ and artifact_generation_id is distinct from p_artifact_generation_id
+ and (
+ (producer = p_producer and metadata->>'generated_by' = p_producer)
+ or (
+ producer is null
+ and (
+ metadata->>'generated_by' = p_producer
+ or (
+ p_producer = 'local-worker'
+ and nullif(metadata->>'generated_by', '') is null
+ and metadata->>'rag_indexing_version' in ('rag-deep-memory-v1', 'rag-deep-memory-v2')
+ )
+ )
+ )
+ );
+
+ if jsonb_typeof(p_aliases) is distinct from 'array' then raise exception 'invalid_maintenance_aliases' using errcode='22023'; end if;
+ -- Alias suggestions remain optional, but any accepted insert shares this exact snapshot.
+ begin
+ insert into public.rag_aliases(owner_id,alias,canonical,alias_type,weight,enabled,metadata)
+ select p_owner_id,r.alias,r.canonical,r.alias_type,r.weight,r.enabled,
+ coalesce(r.metadata,'{}')||jsonb_build_object('document_id',p_document_id)
+ from jsonb_to_recordset(p_aliases) r(alias text,canonical text,alias_type text,weight real,enabled boolean,metadata jsonb);
+ exception when check_violation or not_null_violation or unique_violation then null;
+ end;
+
+ -- This is deliberately the final logical mutation. Any error above rolls
+ -- back both activation and producer-scoped cleanup before metadata advertises
+ -- the new deep-memory generation.
+ perform public.apply_document_metadata_patch(
+ p_document_id,
+ jsonb_build_object(
+ 'rag_indexing_version', p_rag_memory_version,
+ 'rag_memory_version', p_rag_memory_version,
+ 'rag_memory_updated_at', now(),
+ 'document_intelligence_version', p_document_intelligence_version,
+ 'document_intelligence_updated_at', now(),
+ 'section_count', p_section_count,
+ 'memory_card_count', p_memory_card_count,
+ 'index_unit_count', v_expected_index_unit_count,
+ 'index_unit_counts_by_type', coalesce(p_index_unit_counts_by_type, '{}'::jsonb),
+ 'repaired_anchor_count', p_repaired_anchor_count,
+ 'deep_memory_generations', jsonb_build_object(p_producer, p_artifact_generation_id)
+ )
+ );
+
+ select * into v_live_document from public.documents where id=p_document_id;
+ return jsonb_build_object(
+ 'updated_at',v_live_document.updated_at, 'metadata',v_live_document.metadata,
+ 'document_id', p_document_id,
+ 'producer', p_producer,
+ 'artifact_generation_id', p_artifact_generation_id,
+ 'index_generation_id', v_committed_index_generation,
+ 'section_count', p_section_count,
+ 'memory_card_count', p_memory_card_count,
+ 'index_unit_count', v_expected_index_unit_count
+ );
+end;
+$$;
+alter function public.commit_document_maintenance_deep_memory(uuid,uuid,timestamptz,uuid,jsonb,jsonb,text,uuid,text,text,integer,integer,jsonb,integer) owner to postgres;
+revoke all on function public.commit_document_maintenance_deep_memory(uuid,uuid,timestamptz,uuid,jsonb,jsonb,text,uuid,text,text,integer,integer,jsonb,integer) from public,anon,authenticated,service_role;
+grant execute on function public.commit_document_maintenance_deep_memory(uuid,uuid,timestamptz,uuid,jsonb,jsonb,text,uuid,text,text,integer,integer,jsonb,integer) to service_role;
+
+-- No active application/script caller needs either destructive legacy entry point. The worker
+-- uses the separate IngestionFence overload; maintenance now uses the snapshot-fenced commit.
+-- Keep definitions/history intact while closing these obsolete execution paths to app roles.
+revoke all on function public.reset_document_index(uuid) from public,anon,authenticated,service_role;
+revoke all on function public.commit_document_deep_memory_generation(uuid,text,uuid,text,text,integer,integer,jsonb,integer)
+ from public,anon,authenticated,service_role;
diff --git a/supabase/migrations/20261105144902_audit_teaching_link_and_booking_lifecycle.sql b/supabase/migrations/20261105144902_audit_teaching_link_and_booking_lifecycle.sql
new file mode 100644
index 0000000000..e59c820b1e
--- /dev/null
+++ b/supabase/migrations/20261105144902_audit_teaching_link_and_booking_lifecycle.sql
@@ -0,0 +1,91 @@
+-- Enforce the existing twenty-active-link ceiling atomically, including older application writers.
+-- A versioned create authority makes newer apps refuse schemas lacking this contract.
+set local lock_timeout = '5s';
+set local statement_timeout = '30s';
+
+create function public.teaching_guard_epa_pending_limit() returns trigger
+language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$
+declare v_now timestamptz;
+begin
+ if new.status <> 'pending' or new.expires_at <= clock_timestamp() then return new; end if;
+ perform pg_advisory_xact_lock(hashtextextended(new.registrar_id::text, 5112026));
+ v_now := clock_timestamp();
+ if new.expires_at <= v_now then return new; end if;
+ -- A pending update that does not consume another slot may preserve legacy over-limit rows.
+ -- Re-check expiry after acquiring the lock so a waiting renewal cannot revive an uncounted slot.
+ if tg_op = 'UPDATE' and old.registrar_id = new.registrar_id
+ and old.status = 'pending' and old.expires_at > v_now then return new; end if;
+ if (select count(*) from public.teaching_epa_magic_links
+ where registrar_id = new.registrar_id and status = 'pending' and expires_at > v_now
+ and id <> new.id) >= 20 then
+ raise exception 'magic_link_limit_reached';
+ end if;
+ return new;
+end $$;
+
+create trigger teaching_epa_pending_limit_guard
+ before insert or update of registrar_id, status, expires_at on public.teaching_epa_magic_links
+ for each row execute function public.teaching_guard_epa_pending_limit();
+
+create function public.teaching_create_epa_magic_link_v1(p_registrar_id uuid, p_link jsonb)
+returns jsonb language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$
+declare v_link public.teaching_epa_magic_links;
+begin
+ if p_registrar_id is null or jsonb_typeof(p_link) is distinct from 'object' then
+ raise exception 'magic_link_invalid_request';
+ end if;
+ insert into public.teaching_epa_magic_links(
+ registrar_id, token_hash, epa_number, trainee_name, supervisor_name, supervisor_role,
+ clinical_setting, status, expires_at, updated_at
+ ) values (
+ p_registrar_id, p_link->>'token_hash', (p_link->>'epa_number')::smallint,
+ p_link->>'trainee_name', p_link->>'supervisor_name', p_link->>'supervisor_role',
+ p_link->>'clinical_setting', 'pending', (p_link->>'expires_at')::timestamptz,
+ coalesce((p_link->>'updated_at')::timestamptz, now())
+ ) returning * into v_link;
+ return to_jsonb(v_link);
+end $$;
+
+alter function public.teaching_guard_epa_pending_limit() owner to postgres;
+alter function public.teaching_create_epa_magic_link_v1(uuid, jsonb) owner to postgres;
+revoke all on function public.teaching_guard_epa_pending_limit(), public.teaching_create_epa_magic_link_v1(uuid, jsonb)
+ from public, anon, authenticated;
+grant execute on function public.teaching_guard_epa_pending_limit(), public.teaching_create_epa_magic_link_v1(uuid, jsonb)
+ to service_role;
+
+-- Bound the result of overlap checks without comparing raw JSON timestamp text. Existing
+-- cancellation and earlier-created-slot priority semantics remain the same as the app.
+create function public.teaching_find_supervisor_overlap_v1(
+ p_supervisor_id uuid, p_starts_at timestamptz, p_ends_at timestamptz, p_exclude_id uuid,
+ p_before_created_at timestamptz default null, p_before_id uuid default null
+) returns setof public.registrar_assessments
+language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$
+begin
+ if p_supervisor_id is null or p_starts_at is null or p_ends_at is null or p_ends_at <= p_starts_at
+ or p_exclude_id is null or ((p_before_created_at is null) <> (p_before_id is null)) then
+ raise exception 'invalid_booking_window';
+ end if;
+ return query select r.* from public.registrar_assessments r
+ where r.assessor_id = p_supervisor_id and r.category = 'supervisor_review'
+ and r.ratings->>'kind' = 'supervisor_booking' and r.id <> p_exclude_id
+ and (case when r.ratings->>'bookingState' in ('open', 'booked', 'completed', 'cancelled')
+ then r.ratings->>'bookingState' when r.status = 'archived' then 'cancelled' else 'active' end) <> 'cancelled'
+ and (p_before_created_at is null or (r.created_at, r.id) < (p_before_created_at, p_before_id))
+ -- CASE also fences casts from unrelated assessment JSON if the planner reorders filters.
+ and (case when r.assessor_id = p_supervisor_id and r.category = 'supervisor_review'
+ and r.ratings->>'kind' = 'supervisor_booking' then
+ case when jsonb_typeof(r.ratings->'startsAt') = 'string'
+ then (r.ratings->>'startsAt')::timestamptz else r.created_at end
+ else null end) < p_ends_at
+ and (case when r.assessor_id = p_supervisor_id and r.category = 'supervisor_review'
+ and r.ratings->>'kind' = 'supervisor_booking' then
+ case when jsonb_typeof(r.ratings->'endsAt') = 'string'
+ then (r.ratings->>'endsAt')::timestamptz else r.updated_at end
+ else null end) > p_starts_at
+ order by r.created_at, r.id limit 1;
+end $$;
+alter function public.teaching_find_supervisor_overlap_v1(uuid, timestamptz, timestamptz, uuid, timestamptz, uuid) owner to postgres;
+revoke all on function public.teaching_find_supervisor_overlap_v1(uuid, timestamptz, timestamptz, uuid, timestamptz, uuid)
+ from public, anon, authenticated;
+grant execute on function public.teaching_find_supervisor_overlap_v1(uuid, timestamptz, timestamptz, uuid, timestamptz, uuid)
+ to service_role;
diff --git a/supabase/migrations/20261105150056_audit_cpd_entry_concurrency.sql b/supabase/migrations/20261105150056_audit_cpd_entry_concurrency.sql
new file mode 100644
index 0000000000..6e80b3227d
--- /dev/null
+++ b/supabase/migrations/20261105150056_audit_cpd_entry_concurrency.sql
@@ -0,0 +1,173 @@
+-- Full manual CPD edits and amendments replace exactly the stored content their editor read.
+-- Strengthen the legacy RPCs too: old clients without expectedVersion fail closed on edits.
+-- Creates retain request-id idempotency; intentional Teaching create/restore remains separate.
+-- Apply before the app release: new clients use versioned RPCs and never fall back.
+
+set local lock_timeout = '5s';
+set local statement_timeout = '30s';
+
+-- Called only after the writer has locked the owner-scoped entry FOR UPDATE. Allocation
+-- guards take FOR SHARE on that entry, so allocations cannot change across this comparison.
+-- VOLATILE gives this lookup a fresh snapshot after a lock wait. Include copied/archive
+-- state so narrow alternate mutations also invalidate a stale full-record editor.
+create function public.cme_entry_edit_version(p_owner_id uuid, p_entry_id uuid)
+returns jsonb
+language sql volatile security invoker set search_path = public, pg_catalog, pg_temp as $$
+ select jsonb_build_object(
+ 'id',e.id,'date',e.activity_date,'title',e.title,'reflection',e.reflection,
+ 'costCents',e.cost_cents,'routineId',e.routine_id,'documentId',e.document_id,
+ 'sourceUrl',e.source_url,'buckets',to_jsonb(e.buckets),
+ 'formalPeerReviewHours',coalesce(e.formal_peer_review_hours,0),
+ 'transcribed',e.transcribed_at is not null,'archived',e.archived_at is not null,
+ 'allocations',(
+ select coalesce(jsonb_agg(jsonb_build_object('category',a.category,'hours',a.hours) order by a.category),'[]'::jsonb)
+ from public.cme_allocations a where a.owner_id=p_owner_id and a.entry_id=e.id
+ )
+ ) from public.cme_entries e where e.owner_id=p_owner_id and e.id=p_entry_id
+$$;
+
+create or replace function public.cme_save_entry(p_owner_id uuid,p_year_id uuid,p_entry_id uuid,p_entry jsonb,p_create boolean,p_request_id uuid default null) returns jsonb
+language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$
+declare
+ v_year public.cme_years; v_existing public.cme_entries; v_saved public.cme_entries;
+ v_routine public.cme_routines; v_alloc jsonb; v_review numeric; v_total numeric; v_count integer;
+begin
+ if p_owner_id is null or p_year_id is null or p_entry_id is null or p_create is null
+ or jsonb_typeof(p_entry) is distinct from 'object' then raise exception 'cme_invalid_request'; end if;
+ perform pg_advisory_xact_lock(hashtextextended(p_owner_id::text, 23092026));
+ if p_create and p_request_id is not null then
+ select * into v_existing from public.cme_entries where owner_id=p_owner_id and request_id=p_request_id;
+ if found then
+ if v_existing.request_payload is distinct from p_entry then raise exception 'cme_retry_conflict'; end if;
+ return to_jsonb(v_existing) || jsonb_build_object('cme_allocations',(select coalesce(jsonb_agg(jsonb_build_object('category',category,'hours',hours)),'[]') from public.cme_allocations where owner_id=p_owner_id and entry_id=v_existing.id));
+ end if;
+ end if;
+ select * into v_year from public.cme_years where owner_id=p_owner_id and id=p_year_id for update;
+ if not found then raise exception 'cme_year_not_confirmed'; end if;
+ if v_year.closed_at is not null then raise exception 'cme_year_closed'; end if;
+ if extract(year from (p_entry->>'date')::date) <> v_year.year then raise exception 'cme_invalid_request'; end if;
+ if not p_create then
+ select * into v_existing from public.cme_entries where owner_id=p_owner_id and id=p_entry_id for update;
+ if not found then raise exception 'cme_entry_not_found'; end if;
+ if v_existing.archived_at is not null then raise exception 'cme_entry_archived'; end if;
+ if p_entry->'expectedVersion' is distinct from public.cme_entry_edit_version(p_owner_id,p_entry_id) then raise exception 'cme_entry_conflict'; end if;
+ if exists(select 1 from public.cme_years where owner_id=p_owner_id and id=v_existing.year_id and closed_at is not null) then raise exception 'cme_year_closed'; end if;
+ end if;
+ if jsonb_typeof(p_entry->'allocations') is distinct from 'array' then raise exception 'cme_invalid_request'; end if;
+ select count(*),coalesce(sum((a->>'hours')::numeric),0),coalesce(sum((a->>'hours')::numeric) filter(where a->>'category'='reviewing'),0)
+ into v_count,v_total,v_review from jsonb_array_elements(p_entry->'allocations') a;
+ if v_count not between 1 and 3 or v_total > 24 or coalesce((p_entry->>'formalPeerReviewHours')::numeric,0) not between 0 and v_review then raise exception 'cme_invalid_request'; end if;
+ if p_entry->>'documentId' is not null and not exists(select 1 from public.documents where owner_id=p_owner_id and id=(p_entry->>'documentId')::uuid) then raise exception 'cme_invalid_link'; end if;
+ if p_entry->>'routineId' is not null then
+ select * into v_routine from public.cme_routines where owner_id=p_owner_id and id=(p_entry->>'routineId')::uuid for update;
+ if not found or (p_create and v_routine.archived_at is not null) then raise exception 'cme_invalid_link'; end if;
+ end if;
+ if p_create then
+ insert into public.cme_entries(id,owner_id,year_id,activity_date,title,reflection,cost_cents,routine_id,document_id,buckets,formal_peer_review_hours,request_id,request_payload,source_url)
+ values(p_entry_id,p_owner_id,p_year_id,(p_entry->>'date')::date,p_entry->>'title',p_entry->>'reflection',(p_entry->>'costCents')::integer,(p_entry->>'routineId')::uuid,(p_entry->>'documentId')::uuid,array(select jsonb_array_elements_text(p_entry->'buckets')),coalesce((p_entry->>'formalPeerReviewHours')::numeric,0),p_request_id,p_entry,p_entry->>'sourceUrl')
+ returning * into v_saved;
+ else
+ update public.cme_entries set year_id=p_year_id,activity_date=(p_entry->>'date')::date,title=p_entry->>'title',reflection=p_entry->>'reflection',cost_cents=(p_entry->>'costCents')::integer,routine_id=(p_entry->>'routineId')::uuid,document_id=(p_entry->>'documentId')::uuid,buckets=array(select jsonb_array_elements_text(p_entry->'buckets')),formal_peer_review_hours=coalesce((p_entry->>'formalPeerReviewHours')::numeric,0),source_url=p_entry->>'sourceUrl'
+ where owner_id=p_owner_id and id=p_entry_id returning * into v_saved;
+ delete from public.cme_allocations where owner_id=p_owner_id and entry_id=p_entry_id;
+ end if;
+ for v_alloc in select value from jsonb_array_elements(p_entry->'allocations') loop
+ insert into public.cme_allocations(owner_id,entry_id,category,hours) values(p_owner_id,p_entry_id,v_alloc->>'category',(v_alloc->>'hours')::numeric);
+ end loop;
+ -- A routine advances only after the complete entry is saved, in this same
+ -- transaction. An idempotent retry returns above and never advances twice.
+ if p_create and v_routine.id is not null and v_routine.next_due is not null and v_routine.next_due <= (p_entry->>'date')::date then
+ update public.cme_routines set next_due=((p_entry->>'date')::date + case v_routine.cadence when 'weekly' then interval '7 days' when 'monthly' then interval '1 month' else interval '3 months' end)::date where owner_id=p_owner_id and id=v_routine.id;
+ end if;
+ return to_jsonb(v_saved)||jsonb_build_object('cme_allocations',p_entry->'allocations');
+end $$;
+
+create or replace function public.cme_amend_closed_entry(p_owner_id uuid, p_entry_id uuid, p_entry jsonb, p_reason text)
+returns jsonb
+language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$
+declare
+ v_year public.cme_years; v_existing public.cme_entries; v_saved public.cme_entries; v_routine public.cme_routines;
+ v_alloc jsonb; v_review numeric; v_total numeric; v_count integer; v_before jsonb;
+ v_reason text := btrim(coalesce(p_reason, ''));
+begin
+ if p_owner_id is null or p_entry_id is null or jsonb_typeof(p_entry) is distinct from 'object' then
+ raise exception 'cme_invalid_request';
+ end if;
+ if char_length(v_reason) not between 3 and 1000 then raise exception 'cme_amendment_reason_invalid'; end if;
+ perform pg_advisory_xact_lock(hashtextextended(p_owner_id::text, 23092026));
+ select * into v_existing from public.cme_entries where owner_id=p_owner_id and id=p_entry_id for update;
+ if not found then raise exception 'cme_entry_not_found'; end if;
+ if v_existing.archived_at is not null then raise exception 'cme_entry_archived'; end if;
+ if p_entry->'expectedVersion' is distinct from public.cme_entry_edit_version(p_owner_id,p_entry_id) then raise exception 'cme_entry_conflict'; end if;
+ select * into v_year from public.cme_years where owner_id=p_owner_id and id=v_existing.year_id for update;
+ if not found then raise exception 'cme_year_not_confirmed'; end if;
+ if v_year.closed_at is null then raise exception 'cme_year_open'; end if;
+ if extract(year from (p_entry->>'date')::date) <> v_year.year then raise exception 'cme_invalid_request'; end if;
+ if jsonb_typeof(p_entry->'allocations') is distinct from 'array' then raise exception 'cme_invalid_request'; end if;
+ select count(*),coalesce(sum((a->>'hours')::numeric),0),coalesce(sum((a->>'hours')::numeric) filter(where a->>'category'='reviewing'),0)
+ into v_count,v_total,v_review from jsonb_array_elements(p_entry->'allocations') a;
+ if v_count not between 1 and 3 or v_total > 24 or coalesce((p_entry->>'formalPeerReviewHours')::numeric,0) not between 0 and v_review then raise exception 'cme_invalid_request'; end if;
+ if p_entry->>'documentId' is not null and not exists(select 1 from public.documents where owner_id=p_owner_id and id=(p_entry->>'documentId')::uuid) then raise exception 'cme_invalid_link'; end if;
+ if p_entry->>'routineId' is not null then
+ select * into v_routine from public.cme_routines where owner_id=p_owner_id and id=(p_entry->>'routineId')::uuid;
+ if not found then raise exception 'cme_invalid_link'; end if;
+ end if;
+
+ v_before := jsonb_build_object(
+ 'date', v_existing.activity_date, 'title', v_existing.title, 'reflection', v_existing.reflection,
+ 'costCents', v_existing.cost_cents, 'routineId', v_existing.routine_id, 'documentId', v_existing.document_id,
+ 'sourceUrl', v_existing.source_url, 'buckets', to_jsonb(v_existing.buckets),
+ 'formalPeerReviewHours', v_existing.formal_peer_review_hours,
+ 'allocations', (
+ select coalesce(jsonb_agg(jsonb_build_object('category', category, 'hours', hours) order by category), '[]'::jsonb)
+ from public.cme_allocations where owner_id=p_owner_id and entry_id=p_entry_id
+ )
+ );
+ insert into public.cme_year_amendments(owner_id, year_id, entry_id, reason, before, after)
+ values (
+ p_owner_id, v_year.id, p_entry_id, v_reason, v_before,
+ jsonb_build_object(
+ 'date', p_entry->'date', 'title', p_entry->'title', 'reflection', p_entry->'reflection',
+ 'costCents', p_entry->'costCents', 'routineId', p_entry->'routineId', 'documentId', p_entry->'documentId',
+ 'sourceUrl', p_entry->'sourceUrl', 'buckets', p_entry->'buckets',
+ 'formalPeerReviewHours', coalesce(p_entry->'formalPeerReviewHours', '0'::jsonb),
+ 'allocations', p_entry->'allocations'
+ )
+ );
+
+ update public.cme_entries set activity_date=(p_entry->>'date')::date,title=p_entry->>'title',reflection=p_entry->>'reflection',cost_cents=(p_entry->>'costCents')::integer,routine_id=(p_entry->>'routineId')::uuid,document_id=(p_entry->>'documentId')::uuid,buckets=array(select jsonb_array_elements_text(p_entry->'buckets')),formal_peer_review_hours=coalesce((p_entry->>'formalPeerReviewHours')::numeric,0),source_url=p_entry->>'sourceUrl'
+ where owner_id=p_owner_id and id=p_entry_id returning * into v_saved;
+ delete from public.cme_allocations where owner_id=p_owner_id and entry_id=p_entry_id;
+ for v_alloc in select value from jsonb_array_elements(p_entry->'allocations') loop
+ insert into public.cme_allocations(owner_id,entry_id,category,hours) values(p_owner_id,p_entry_id,v_alloc->>'category',(v_alloc->>'hours')::numeric);
+ end loop;
+ return to_jsonb(v_saved)||jsonb_build_object('cme_allocations',p_entry->'allocations');
+end $$;
+
+-- Versioned names prevent new app/old database overlap from silently ignoring the guard.
+create function public.cme_save_entry_v2(p_owner_id uuid,p_year_id uuid,p_entry_id uuid,p_entry jsonb,p_create boolean,p_request_id uuid default null)
+returns jsonb language sql volatile security invoker set search_path = public, pg_catalog, pg_temp as $$
+ select public.cme_save_entry(p_owner_id,p_year_id,p_entry_id,p_entry,p_create,p_request_id)
+$$;
+create function public.cme_amend_closed_entry_v2(p_owner_id uuid,p_entry_id uuid,p_entry jsonb,p_reason text)
+returns jsonb language sql volatile security invoker set search_path = public, pg_catalog, pg_temp as $$
+ select public.cme_amend_closed_entry(p_owner_id,p_entry_id,p_entry,p_reason)
+$$;
+
+revoke all on function
+ public.cme_entry_edit_version(uuid,uuid),
+ public.cme_save_entry(uuid,uuid,uuid,jsonb,boolean,uuid),
+ public.cme_amend_closed_entry(uuid,uuid,jsonb,text),
+ public.cme_save_entry_v2(uuid,uuid,uuid,jsonb,boolean,uuid),
+ public.cme_amend_closed_entry_v2(uuid,uuid,jsonb,text)
+from public, anon, authenticated;
+grant execute on function
+ public.cme_entry_edit_version(uuid,uuid),
+ public.cme_save_entry(uuid,uuid,uuid,jsonb,boolean,uuid),
+ public.cme_amend_closed_entry(uuid,uuid,jsonb,text),
+ public.cme_save_entry_v2(uuid,uuid,uuid,jsonb,boolean,uuid),
+ public.cme_amend_closed_entry_v2(uuid,uuid,jsonb,text)
+to service_role;
+alter function public.cme_entry_edit_version(uuid,uuid) owner to postgres;
+alter function public.cme_save_entry_v2(uuid,uuid,uuid,jsonb,boolean,uuid) owner to postgres;
+alter function public.cme_amend_closed_entry_v2(uuid,uuid,jsonb,text) owner to postgres;
diff --git a/supabase/migrations/20261105163543_audit_retire_unfenced_document_reset.sql b/supabase/migrations/20261105163543_audit_retire_unfenced_document_reset.sql
new file mode 100644
index 0000000000..0d00a2ccb6
--- /dev/null
+++ b/supabase/migrations/20261105163543_audit_retire_unfenced_document_reset.sql
@@ -0,0 +1,9 @@
+-- Retire the confirmed destructive, unfenced reset authority after first-party callers
+-- moved to generation-preserving enqueue/maintenance. Historical migration text and
+-- validators remain immutable. Old maintenance tooling must be quiesced before rollout.
+set local lock_timeout = '5s';
+set local statement_timeout = '30s';
+
+-- RESTRICT intentionally refuses an undiscovered catalog dependency; never cascade.
+-- The fenced worker reset_ingestion_attempt_artifacts(jsonb,uuid) is unchanged.
+drop function public.reset_document_index(uuid) restrict;
diff --git a/supabase/schema.sql b/supabase/schema.sql
index 39cbca6446..7c4643814c 100644
--- a/supabase/schema.sql
+++ b/supabase/schema.sql
@@ -39983,3 +39983,1621 @@ begin
perform public.work_sync_team_supervision(v_service, null);
end loop;
end $$;
+
+
+-- supabase/migrations/20261105144240_audit_private_evidence_lifecycle_and_drift.sql
+-- Permanent document removal also removes reconstructable ingestion evidence.
+-- Active/rollback evidence remains immutable while its parent document exists.
+-- This drafts no age-based retention, legal hold, historical orphan sweep or backup purge.
+set local lock_timeout = '5s';
+set local statement_timeout = '60s';
+
+create or replace function ingestion_generation_evidence.immutable() returns trigger
+language plpgsql set search_path='' as $$ declare worker text; attempt jsonb; begin
+ -- A real parent deletion is the only lifecycle exception. No caller-controlled bypass.
+ if tg_op='DELETE' then
+ if tg_table_schema='ingestion_generation_evidence' and tg_level='ROW'
+ and pg_catalog.pg_trigger_depth() > 1 and current_user='postgres'
+ and not exists (select 1 from public.documents d where d.id=old.document_id) then
+ return old;
+ end if;
+ end if;
+ if tg_table_name='candidates' and tg_op='UPDATE' then
+ if old.expected_inventory_digest is null
+ and new.expected_inventory_digest ~ '^sha256:[0-9a-f]{64}$'
+ and to_jsonb(old)-'expected_inventory_digest'=to_jsonb(new)-'expected_inventory_digest'
+ and current_user='postgres' then return new; end if;
+ if current_user='postgres' and old.source_section_layout is null and old.source_section_layout_bytes is null
+ and old.source_section_layout_digest is null and new.source_section_layout is not null
+ and new.source_section_layout_bytes is not null and new.source_section_layout_digest is not null
+ and to_jsonb(old)-array['source_section_layout','source_section_layout_bytes','source_section_layout_digest']
+ =to_jsonb(new)-array['source_section_layout','source_section_layout_bytes','source_section_layout_digest'] then
+ select locked_by into worker from public.ingestion_jobs where id=old.job_id and document_id=old.document_id;
+ attempt:=public.assert_current_ingestion_attempt(jsonb_build_object('job_kind','node','job_id',old.job_id,
+ 'document_id',old.document_id,'worker_id',worker,'lease_token',old.first_lease_token,
+ 'expected_generation_id',old.predecessor_generation_id,'authority_digest',old.authority_digest),old.generation_id);
+ if attempt is not null then
+ perform ingestion_generation_evidence.assert_admin_text_source_section_layout(new::ingestion_generation_evidence.candidates);
+ return new;
+ end if;
+ end if;
+ end if;
+ raise exception 'immutable_ingestion_generation_evidence' using errcode='23514';
+end $$;
+
+alter function ingestion_generation_evidence.immutable() owner to postgres;
+revoke all on function ingestion_generation_evidence.immutable() from public, anon, authenticated, service_role;
+
+create or replace function ingestion_generation_evidence.purge_deleted_document()
+returns trigger
+language plpgsql security definer
+set search_path = ''
+as $purge$
+begin
+ if tg_table_schema <> 'public' or tg_table_name <> 'documents' or tg_op <> 'DELETE'
+ or tg_when <> 'AFTER' or tg_level <> 'ROW'
+ or exists (select 1 from public.documents d where d.id = old.id) then
+ raise exception 'permanent_document_delete_required' using errcode = '23514';
+ end if;
+ -- Child-first order preserves all existing FK and immutability constraints.
+ delete from ingestion_generation_evidence.managed_vector_final_receipts where document_id = old.id;
+ delete from ingestion_generation_evidence.managed_vector_chunk_rows where document_id = old.id;
+ delete from ingestion_generation_evidence.managed_vector_field_rows where document_id = old.id;
+ delete from ingestion_generation_evidence.managed_vector_index_rows where document_id = old.id;
+ delete from ingestion_generation_evidence.scoped_projection_artifacts where document_id = old.id;
+ delete from ingestion_generation_evidence.visual_unit_rows where document_id = old.id;
+ delete from ingestion_generation_evidence.visual_unit_expectations where document_id = old.id;
+ delete from ingestion_generation_evidence.scoped_projection_rows where document_id = old.id;
+ delete from ingestion_generation_evidence.scoped_projection_expectations where document_id = old.id;
+ delete from ingestion_generation_evidence.retained_artifacts where document_id = old.id;
+ delete from ingestion_generation_evidence.outcomes where document_id = old.id;
+ delete from ingestion_generation_evidence.unit_declarations where document_id = old.id;
+ delete from ingestion_generation_evidence.producer_declarations where document_id = old.id;
+ delete from ingestion_generation_evidence.prerequisite_accounting where document_id = old.id;
+ delete from ingestion_generation_evidence.candidate_chunk_rows where document_id = old.id;
+ delete from ingestion_generation_evidence.managed_vector_packets where document_id = old.id;
+ delete from ingestion_generation_evidence.candidates where document_id = old.id;
+ return old;
+end;
+$purge$;
+alter function ingestion_generation_evidence.purge_deleted_document() owner to postgres;
+revoke all on function ingestion_generation_evidence.purge_deleted_document() from public, anon, authenticated, service_role;
+create trigger purge_private_ingestion_evidence_after_document_delete
+ after delete on public.documents for each row
+ execute function ingestion_generation_evidence.purge_deleted_document();
+
+-- Serialize permanent document deletion with ingestion job creation. The
+-- parent row lock conflicts with the FK key-share lock taken by a concurrent
+-- ingestion_jobs insert: either the job commits first and deletion returns an
+-- active_job outcome, or deletion commits first and the insert receives 23503.
+create or replace function public.delete_document_if_idle(
+ p_document_id uuid,
+ p_owner_id uuid,
+ p_document_bucket text,
+ p_image_bucket text
+)
+returns jsonb
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ v_document public.documents%rowtype;
+ v_active_job public.ingestion_jobs%rowtype;
+ v_active_agent_job public.indexing_v3_agent_jobs%rowtype;
+ v_cleanup_job_id uuid;
+ v_image_paths text[] := '{}'::text[];
+ v_chunk_ids uuid[] := '{}'::uuid[];
+begin
+ if p_document_id is null or p_owner_id is null then
+ raise exception 'Document and owner identifiers are required.' using errcode = '22023';
+ end if;
+ if nullif(btrim(p_document_bucket), '') is null or nullif(btrim(p_image_bucket), '') is null then
+ raise exception 'Storage bucket names are required.' using errcode = '22023';
+ end if;
+
+ select d.*
+ into v_document
+ from public.documents d
+ where d.id = p_document_id
+ and d.owner_id = p_owner_id
+ for update;
+
+ if not found then
+ return jsonb_build_object('outcome', 'not_found');
+ end if;
+
+ select j.*
+ into v_active_job
+ from public.ingestion_jobs j
+ where j.document_id = p_document_id
+ and j.status in ('pending', 'processing')
+ order by j.created_at asc, j.id asc
+ limit 1;
+
+ if found then
+ return jsonb_build_object(
+ 'outcome', 'active_job',
+ 'job_id', v_active_job.id,
+ 'job_status', v_active_job.status
+ );
+ end if;
+
+ -- Agent-only work also owns the document. Its FK insert takes the same parent
+ -- key-share lock, so this check is serialized with admission just like node work.
+ select j.* into v_active_agent_job
+ from public.indexing_v3_agent_jobs j
+ where j.document_id = p_document_id and j.status in ('pending', 'processing')
+ order by j.created_at asc, j.id asc limit 1;
+ if found then
+ return jsonb_build_object('outcome', 'active_job', 'job_id', v_active_agent_job.id,
+ 'job_status', v_active_agent_job.status);
+ end if;
+
+ select coalesce(array_agg(distinct i.storage_path order by i.storage_path)
+ filter (where i.storage_path is not null and btrim(i.storage_path) <> ''), '{}'::text[])
+ into v_image_paths
+ from public.document_images i
+ where i.document_id = p_document_id;
+
+ select coalesce(array_agg(c.id order by c.id), '{}'::uuid[])
+ into v_chunk_ids
+ from public.document_chunks c
+ where c.document_id = p_document_id;
+
+ insert into public.storage_cleanup_jobs (
+ owner_id,
+ document_id,
+ document_title,
+ document_bucket,
+ document_paths,
+ image_bucket,
+ image_paths,
+ status,
+ metadata
+ ) values (
+ p_owner_id,
+ p_document_id,
+ v_document.title,
+ p_document_bucket,
+ case
+ when v_document.storage_path is null or btrim(v_document.storage_path) = '' then '{}'::text[]
+ else array[v_document.storage_path]
+ end,
+ p_image_bucket,
+ v_image_paths,
+ 'pending',
+ jsonb_build_object(
+ 'operation', 'permanent_document_delete',
+ 'created_by', 'delete_document_if_idle'
+ )
+ )
+ returning id into v_cleanup_job_id;
+
+ if cardinality(v_chunk_ids) > 0 then
+ delete from public.rag_queries
+ where source_chunk_ids && v_chunk_ids;
+ delete from public.rag_query_misses
+ where top_chunk_ids && v_chunk_ids
+ or cited_chunk_ids && v_chunk_ids;
+ end if;
+
+ delete from public.rag_query_misses
+ where clicked_document_id = p_document_id
+ or expected_document_id = p_document_id;
+
+ delete from public.rag_response_cache
+ where owner_id = p_owner_id
+ and cache_kind in ('search', 'answer');
+
+ delete from public.documents where id = p_document_id;
+
+ return jsonb_build_object(
+ 'outcome', 'deleted',
+ 'cleanup_job_id', v_cleanup_job_id,
+ 'document_title', v_document.title,
+ 'source_path', v_document.storage_path,
+ 'image_paths', to_jsonb(v_image_paths)
+ );
+end;
+$$;
+
+revoke all on function public.delete_document_if_idle(uuid, uuid, text, text)
+ from public, anon, authenticated;
+grant execute on function public.delete_document_if_idle(uuid, uuid, text, text)
+ to service_role;
+
+-- Explicit application-owned private schema only. Platform schemas remain excluded.
+create or replace function ingestion_generation_evidence.schema_drift_inventory()
+returns jsonb
+language sql stable
+set search_path = ''
+as $inventory$
+ with owned_schema as (
+ select n.* from pg_catalog.pg_namespace n
+ where n.nspname = 'ingestion_generation_evidence'
+ and not exists (select 1 from pg_catalog.pg_depend d
+ where d.classid = 'pg_catalog.pg_namespace'::regclass and d.objid = n.oid and d.deptype = 'e')
+ ), objects as (
+ select 'schema:' || n.nspname as identity, jsonb_build_object(
+ 'owner', pg_catalog.pg_get_userbyid(n.nspowner),
+ 'acl', (select jsonb_agg(a.item::text order by a.item::text)
+ from unnest(coalesce(n.nspacl, pg_catalog.acldefault('n', n.nspowner))) a(item))
+ ) as state from owned_schema n
+ union all
+ select 'relation:' || n.nspname || '.' || c.relname, jsonb_build_object(
+ 'kind', c.relkind, 'owner', pg_catalog.pg_get_userbyid(c.relowner),
+ 'acl', (select jsonb_agg(a.item::text order by a.item::text)
+ from unnest(coalesce(c.relacl, pg_catalog.acldefault(case when c.relkind = 'S' then 'S'::"char" else 'r'::"char" end, c.relowner))) a(item)),
+ 'rls_enabled', c.relrowsecurity, 'rls_forced', c.relforcerowsecurity,
+ 'reloptions', (select jsonb_agg(o.opt order by o.opt) from unnest(c.reloptions) o(opt)),
+ 'view_definition', case when c.relkind in ('v','m') then pg_catalog.pg_get_viewdef(c.oid) end,
+ 'sequence', (select to_jsonb(s) - 'seqrelid' from pg_catalog.pg_sequence s where s.seqrelid = c.oid),
+ 'columns', (select jsonb_agg(jsonb_build_object(
+ 'name', a.attname, 'type', pg_catalog.format_type(a.atttypid, a.atttypmod),
+ 'not_null', a.attnotnull, 'identity', a.attidentity, 'generated', a.attgenerated,
+ 'default', pg_catalog.pg_get_expr(ad.adbin, ad.adrelid),
+ 'acl', (select jsonb_agg(x.item::text order by x.item::text) from unnest(a.attacl) x(item))
+ ) order by a.attname) from pg_catalog.pg_attribute a
+ left join pg_catalog.pg_attrdef ad on ad.adrelid = a.attrelid and ad.adnum = a.attnum
+ where a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped),
+ 'constraints', (select jsonb_agg(jsonb_build_object(
+ 'name', x.conname, 'definition', pg_catalog.pg_get_constraintdef(x.oid),
+ 'validated', x.convalidated, 'deferrable', x.condeferrable, 'deferred', x.condeferred
+ ) order by x.conname) from pg_catalog.pg_constraint x where x.conrelid = c.oid),
+ 'indexes', (select jsonb_agg(jsonb_build_object(
+ 'identity', ni.nspname || '.' || ci.relname, 'definition', pg_catalog.pg_get_indexdef(i.indexrelid),
+ 'valid', i.indisvalid, 'ready', i.indisready, 'live', i.indislive
+ ) order by ci.relname) from pg_catalog.pg_index i
+ join pg_catalog.pg_class ci on ci.oid = i.indexrelid
+ join pg_catalog.pg_namespace ni on ni.oid = ci.relnamespace where i.indrelid = c.oid),
+ 'triggers', (select jsonb_agg(jsonb_build_object(
+ 'name', t.tgname, 'enabled', t.tgenabled, 'definition', pg_catalog.pg_get_triggerdef(t.oid)
+ ) order by t.tgname) from pg_catalog.pg_trigger t where t.tgrelid = c.oid and not t.tgisinternal),
+ 'policies', (select jsonb_agg(jsonb_build_object(
+ 'name', p.polname, 'permissive', p.polpermissive, 'command', p.polcmd,
+ 'roles', (select jsonb_agg(case when r.role = 0 then 'public' else pg_catalog.pg_get_userbyid(r.role) end
+ order by case when r.role = 0 then 'public' else pg_catalog.pg_get_userbyid(r.role) end) from unnest(p.polroles) r(role)),
+ 'qual', pg_catalog.pg_get_expr(p.polqual, p.polrelid),
+ 'with_check', pg_catalog.pg_get_expr(p.polwithcheck, p.polrelid)
+ ) order by p.polname) from pg_catalog.pg_policy p where p.polrelid = c.oid)
+ ) from pg_catalog.pg_class c join owned_schema n on n.oid = c.relnamespace
+ where c.relkind in ('r','p','v','m','f','S') and not exists (
+ select 1 from pg_catalog.pg_depend d where d.classid = 'pg_catalog.pg_class'::regclass
+ and d.objid = c.oid and d.deptype = 'e')
+ union all
+ select 'function:' || p.oid::regprocedure::text, jsonb_build_object(
+ 'owner', pg_catalog.pg_get_userbyid(p.proowner),
+ -- Hash complete SQL, including quoted literals. Only normalize platform line endings.
+ 'def_hash', md5(replace(pg_catalog.pg_get_functiondef(p.oid), E'\r\n', E'\n')),
+ 'config', (select jsonb_agg(v.item order by v.item) from unnest(p.proconfig) v(item)),
+ 'security_definer', p.prosecdef,
+ 'acl', (select jsonb_agg(a.item::text order by a.item::text)
+ from unnest(coalesce(p.proacl, pg_catalog.acldefault('f', p.proowner))) a(item))
+ ) from pg_catalog.pg_proc p join owned_schema n on n.oid = p.pronamespace
+ where p.prokind in ('f','p') and not exists (
+ select 1 from pg_catalog.pg_depend d where d.classid = 'pg_catalog.pg_proc'::regclass
+ and d.objid = p.oid and d.deptype = 'e')
+ union all
+ select 'default_acl:' || pg_catalog.pg_get_userbyid(d.defaclrole) || ':' ||
+ coalesce(n.nspname, '*') || ':' || d.defaclobjtype::text, jsonb_build_object(
+ 'acl', (select jsonb_agg(a.item::text order by a.item::text) from unnest(d.defaclacl) a(item))
+ ) from pg_catalog.pg_default_acl d
+ left join pg_catalog.pg_namespace n on n.oid = d.defaclnamespace
+ where d.defaclnamespace in (select oid from owned_schema)
+ or (d.defaclnamespace = 0 and d.defaclrole = 'postgres'::regrole)
+ )
+ select coalesce(jsonb_agg(jsonb_build_object('identity', identity, 'state', state) order by identity), '[]'::jsonb)
+ from objects;
+$inventory$;
+alter function ingestion_generation_evidence.schema_drift_inventory() owner to postgres;
+revoke all on function ingestion_generation_evidence.schema_drift_inventory() from public, anon, authenticated, service_role;
+
+create or replace function public.schema_drift_snapshot()
+returns jsonb
+language plpgsql
+stable
+security definer
+set search_path to ''
+as $$
+declare
+ snapshot jsonb;
+ buckets jsonb := '[]'::jsonb;
+ history jsonb := '[]'::jsonb;
+ history_probe text := 'no_history_table';
+begin
+ select jsonb_build_object(
+ 'snapshot_version', 2,
+ 'captured_at', now(),
+ 'private_objects', ingestion_generation_evidence.schema_drift_inventory(),
+ 'extensions', coalesce((
+ select jsonb_agg(jsonb_build_object('name', e.extname, 'schema', n.nspname) order by e.extname)
+ from pg_extension e
+ join pg_namespace n on n.oid = e.extnamespace
+ where e.extname <> 'plpgsql'
+ ), '[]'::jsonb),
+ 'tables', coalesce((
+ select jsonb_agg(jsonb_build_object(
+ 'name', c.relname,
+ 'rls_enabled', c.relrowsecurity,
+ 'rls_forced', c.relforcerowsecurity,
+ 'reloptions', (select array_agg(o.opt order by o.opt) from unnest(c.reloptions) o(opt)),
+ 'acl', (select array_agg(a.item::text order by a.item::text) from unnest(coalesce(c.relacl, acldefault('r', c.relowner))) a(item)),
+ 'columns', (
+ select jsonb_agg(jsonb_build_object(
+ 'name', att.attname,
+ 'type', format_type(att.atttypid, att.atttypmod),
+ 'not_null', att.attnotnull,
+ 'identity', att.attidentity,
+ 'generated', att.attgenerated,
+ 'default', pg_get_expr(ad.adbin, ad.adrelid)
+ ) order by att.attname)
+ from pg_attribute att
+ left join pg_attrdef ad on ad.adrelid = att.attrelid and ad.adnum = att.attnum
+ where att.attrelid = c.oid and att.attnum > 0 and not att.attisdropped
+ )
+ ) order by c.relname)
+ from pg_class c
+ where c.relnamespace = 'public'::regnamespace and c.relkind = 'r'
+ ), '[]'::jsonb),
+ 'views', coalesce((
+ select jsonb_agg(jsonb_build_object(
+ 'name', c.relname,
+ 'def_hash', md5(regexp_replace(pg_get_viewdef(c.oid), '\s+', '', 'g'))
+ ) order by c.relname)
+ from pg_class c
+ where c.relnamespace = 'public'::regnamespace and c.relkind in ('v', 'm')
+ ), '[]'::jsonb),
+ 'functions', coalesce((
+ select jsonb_agg(jsonb_build_object(
+ 'signature', p.oid::regprocedure::text,
+ -- A '--' starts a line comment only where the text before it on that line holds no
+ -- open quote, so a '--' inside a '...' or "..." value (a URL such as
+ -- Next-Step---Drug-..., a Markdown '| --- |' row) is hashed as content.
+ 'def_hash', md5(regexp_replace(regexp_replace(regexp_replace(pg_get_functiondef(p.oid), '/\*.*?\*/', '', 'gs'), '(^|\n)((?:[^''"\n-]|-(?!-)|''[^''\n]*''|"[^"\n]*")*)--[^\n]*', '\1\2', 'g'), '\s+', '', 'g')),
+ 'acl', (select array_agg(a.item::text order by a.item::text) from unnest(coalesce(p.proacl, acldefault('f', p.proowner))) a(item))
+ ) order by p.oid::regprocedure::text)
+ from pg_proc p
+ where p.pronamespace = 'public'::regnamespace
+ and p.prokind = 'f'
+ and not exists (
+ select 1 from pg_depend dep
+ where dep.classid = 'pg_proc'::regclass and dep.objid = p.oid and dep.deptype = 'e'
+ )
+ ), '[]'::jsonb),
+ 'indexes', coalesce((
+ select jsonb_agg(jsonb_build_object(
+ 'name', ci.relname,
+ 'table', ct.relname,
+ 'def', pg_get_indexdef(ci.oid),
+ 'def_hash', md5(regexp_replace(pg_get_indexdef(ci.oid), '\s+', '', 'g'))
+ ) order by ci.relname)
+ from pg_index i
+ join pg_class ci on ci.oid = i.indexrelid
+ join pg_class ct on ct.oid = i.indrelid
+ where ct.relnamespace = 'public'::regnamespace
+ and ci.relnamespace = 'public'::regnamespace
+ ), '[]'::jsonb),
+ 'policies', coalesce((
+ select jsonb_agg(jsonb_build_object(
+ 'schema', pol.schemaname,
+ 'table', pol.tablename,
+ 'name', pol.policyname,
+ 'permissive', pol.permissive,
+ 'roles', (select array_agg(r.role::text order by r.role::text) from unnest(pol.roles) r(role)),
+ 'cmd', pol.cmd,
+ 'qual', pol.qual,
+ 'with_check', pol.with_check
+ ) order by pol.schemaname, pol.tablename, pol.policyname)
+ from pg_policies pol
+ where pol.schemaname in ('public', 'storage')
+ ), '[]'::jsonb),
+ 'constraints', coalesce((
+ select jsonb_agg(jsonb_build_object(
+ 'table', ct.relname,
+ 'name', con.conname,
+ 'def', pg_get_constraintdef(con.oid)
+ ) order by ct.relname, con.conname)
+ from pg_constraint con
+ join pg_class ct on ct.oid = con.conrelid
+ where con.connamespace = 'public'::regnamespace and ct.relkind = 'r'
+ ), '[]'::jsonb),
+ 'triggers', coalesce((
+ select jsonb_agg(jsonb_build_object(
+ 'table', ct.relname,
+ 'name', t.tgname,
+ 'def', pg_get_triggerdef(t.oid)
+ ) order by ct.relname, t.tgname)
+ from pg_trigger t
+ join pg_class ct on ct.oid = t.tgrelid
+ where ct.relnamespace = 'public'::regnamespace and not t.tgisinternal
+ ), '[]'::jsonb)
+ ) into snapshot;
+
+ if to_regclass('storage.buckets') is not null then
+ execute 'select coalesce(jsonb_agg(jsonb_build_object('
+ || '''id'', b.id, ''public'', b.public, ''file_size_limit'', b.file_size_limit, '
+ || '''allowed_mime_types'', b.allowed_mime_types) order by b.id), ''[]''::jsonb) '
+ || 'from storage.buckets b'
+ into buckets;
+ end if;
+
+ -- Migration-history integrity probe. A version recorded without executed
+ -- statements is the fingerprint of a history repair / mark-applied row and
+ -- must be covered by a fail-fast guard migration + a reviewed allowlist entry
+ -- (docs/database-drift-detection.md, "Guard-migration contract").
+ if to_regclass('supabase_migrations.schema_migrations') is not null then
+ if exists (
+ select 1
+ from pg_attribute att
+ where att.attrelid = 'supabase_migrations.schema_migrations'::regclass
+ and att.attname = 'statements'
+ and att.attnum > 0
+ and not att.attisdropped
+ ) then
+ execute 'select coalesce(jsonb_agg(jsonb_build_object('
+ || '''version'', m.version, ''name'', m.name, '
+ || '''signal'', case when m.statements is null then ''null'' else ''empty'' end'
+ || ') order by m.version), ''[]''::jsonb) '
+ || 'from supabase_migrations.schema_migrations m '
+ || 'where m.statements is null or cardinality(m.statements) = 0'
+ into history;
+ history_probe := 'ok';
+ else
+ history_probe := 'no_statements_column';
+ end if;
+ end if;
+
+ return snapshot || jsonb_build_object(
+ 'storage_buckets', buckets,
+ 'migration_history', history,
+ 'migration_history_probe', history_probe
+ );
+end;
+$$;
+
+revoke execute on function public.schema_drift_snapshot() from public, anon, authenticated;
+grant execute on function public.schema_drift_snapshot() to service_role;
+
+
+-- supabase/migrations/20261105144246_audit_cpd_close_coherence.sql
+-- Bind annual statuses to every evaluator input; retain immutable snapshots and reasoned amendments.
+-- Older application instances lacking inputs fail closed rather than freezing an unchecked evaluation.
+set local lock_timeout = '5s';
+set local statement_timeout = '30s';
+
+-- Requirement task updates and direct allocation writes must serialize with close too.
+-- Lock both sides of a move in year-id order. Account/year cascades find no year and pass.
+create or replace function public.cme_guard_requirement() returns trigger
+language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$
+declare v_year public.cme_years;
+begin
+ for v_year in
+ select y.* from public.cme_years y
+ where (tg_op <> 'INSERT' and y.id = old.year_id and y.owner_id = old.owner_id)
+ or (tg_op <> 'DELETE' and y.id = new.year_id and y.owner_id = new.owner_id)
+ order by y.id for update
+ loop
+ if v_year.closed_at is not null then raise exception 'cme_year_closed'; end if;
+ end loop;
+ if tg_op = 'DELETE' then return old; end if;
+ return new;
+end $$;
+
+create or replace function public.cme_guard_allocation() returns trigger
+language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$
+declare v_entry public.cme_entries; v_closed_at timestamptz;
+begin
+ -- Lock the entry before reading its year in a separate statement. A join locking only
+ -- the year can retain a stale association if an entry moves to another year while waiting.
+ for v_entry in
+ select e.* from public.cme_entries e
+ where (tg_op <> 'INSERT' and e.id = old.entry_id and e.owner_id = old.owner_id)
+ or (tg_op <> 'DELETE' and e.id = new.entry_id and e.owner_id = new.owner_id)
+ order by e.id for share
+ loop
+ select y.closed_at into v_closed_at from public.cme_years y
+ where y.id = v_entry.year_id and y.owner_id = v_entry.owner_id for update;
+ if v_closed_at is not null
+ and not public.cme_entry_amendment_in_progress(v_entry.id, v_entry.owner_id) then
+ raise exception 'cme_year_closed';
+ end if;
+ end loop;
+ if tg_op = 'DELETE' then return old; end if;
+ return new;
+end $$;
+
+create or replace function public.cme_close_year(p_owner_id uuid, p_year_id uuid, p_evaluation jsonb, p_shortfall_note text default null)
+returns jsonb
+language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$
+declare
+ v_year public.cme_years; v_snapshot public.cme_year_snapshots; v_record jsonb; v_inputs jsonb;
+ v_total numeric; v_count integer; v_note text := nullif(btrim(coalesce(p_shortfall_note, '')), '');
+begin
+ if p_owner_id is null or p_year_id is null or jsonb_typeof(p_evaluation) is distinct from 'object' then
+ raise exception 'cme_invalid_request';
+ end if;
+ if v_note is not null and char_length(v_note) > 2000 then raise exception 'cme_invalid_request'; end if;
+ perform pg_advisory_xact_lock(hashtextextended(p_owner_id::text, 23092026));
+ select * into v_year from public.cme_years where owner_id=p_owner_id and id=p_year_id for update;
+ if not found then raise exception 'cme_year_not_confirmed'; end if;
+ if v_year.closed_at is not null then raise exception 'cme_year_closed'; end if;
+
+ select count(*), coalesce(sum(a.hours), 0) into v_count, v_total
+ from public.cme_entries e
+ left join lateral (
+ select sum(hours) as hours from public.cme_allocations where owner_id=p_owner_id and entry_id=e.id
+ ) a on true
+ where e.owner_id=p_owner_id and e.year_id=p_year_id and e.archived_at is null;
+ if round(v_total, 2) is distinct from round((p_evaluation->>'totalHours')::numeric, 2)
+ or v_count is distinct from (p_evaluation->>'entryCount')::integer then
+ raise exception 'cme_close_conflict';
+ end if;
+
+ v_record := jsonb_build_object(
+ 'year', v_year.year,
+ 'totalHours', v_year.total_hours,
+ 'confirmedOn', v_year.confirmed_on,
+ 'confirmedSource', v_year.confirmed_source,
+ 'requirements', (
+ select coalesce(jsonb_agg(jsonb_build_object(
+ 'id', r.id, 'label', r.label, 'source', r.source, 'spec', r.spec, 'completedOn', r.completed_on
+ ) order by r.sort_order, r.id), '[]'::jsonb)
+ from public.cme_requirements r where r.owner_id=p_owner_id and r.year_id=p_year_id
+ ),
+ 'entries', (
+ select coalesce(jsonb_agg(jsonb_build_object(
+ 'id', e.id, 'date', e.activity_date, 'title', e.title, 'reflection', e.reflection,
+ 'costCents', e.cost_cents, 'routineId', e.routine_id, 'documentId', e.document_id,
+ 'sourceUrl', e.source_url, 'buckets', to_jsonb(e.buckets),
+ 'formalPeerReviewHours', e.formal_peer_review_hours,
+ 'allocations', (
+ select coalesce(jsonb_agg(jsonb_build_object('category', a.category, 'hours', a.hours) order by a.category), '[]'::jsonb)
+ from public.cme_allocations a where a.owner_id=p_owner_id and a.entry_id=e.id
+ )
+ ) order by e.activity_date, e.id), '[]'::jsonb)
+ from public.cme_entries e where e.owner_id=p_owner_id and e.year_id=p_year_id and e.archived_at is null
+ )
+ );
+
+ -- Compare the complete inputs used by evaluateYear(), not only the lossy total/count.
+ -- The same year lock is taken by every requirement, activity and allocation writer.
+ v_inputs := jsonb_build_object(
+ 'year', v_record->'year', 'totalHours', v_record->'totalHours',
+ 'confirmedOn', v_record->'confirmedOn', 'confirmedSource', v_record->'confirmedSource',
+ 'requirements', (select coalesce(jsonb_agg(r order by r->>'id'), '[]'::jsonb)
+ from jsonb_array_elements(v_record->'requirements') r),
+ 'entries', (select coalesce(jsonb_agg(jsonb_build_object(
+ 'id', e->'id', 'buckets', e->'buckets',
+ 'formalPeerReviewHours', e->'formalPeerReviewHours', 'allocations', e->'allocations'
+ ) order by e->>'id'), '[]'::jsonb) from jsonb_array_elements(v_record->'entries') e)
+ );
+ if p_evaluation->'inputs' is distinct from v_inputs then
+ raise exception 'cme_close_conflict';
+ end if;
+
+ insert into public.cme_year_snapshots(owner_id, year_id, shortfall_note, record, evaluation, total_hours, target_hours)
+ values (p_owner_id, p_year_id, v_note, v_record, p_evaluation, round(v_total, 2), v_year.total_hours)
+ returning * into v_snapshot;
+ update public.cme_years set closed_at = v_snapshot.closed_at, shortfall_note = v_note
+ where owner_id=p_owner_id and id=p_year_id;
+ return to_jsonb(v_snapshot);
+end $$;
+
+-- New app instances must refuse old schemas rather than letting the legacy RPC ignore inputs.
+-- Old app instances on the new schema also fail closed in cme_close_year without inputs.
+create function public.cme_close_year_v2(p_owner_id uuid, p_year_id uuid, p_evaluation jsonb, p_shortfall_note text default null)
+returns jsonb language sql security invoker set search_path = public, pg_catalog, pg_temp as $$
+ select public.cme_close_year(p_owner_id, p_year_id, p_evaluation, p_shortfall_note)
+$$;
+alter function public.cme_close_year_v2(uuid, uuid, jsonb, text) owner to postgres;
+revoke all on function public.cme_close_year_v2(uuid, uuid, jsonb, text) from public, anon, authenticated;
+grant execute on function public.cme_close_year_v2(uuid, uuid, jsonb, text) to service_role;
+
+
+-- supabase/migrations/20261105144248_audit_hospital_lifecycle.sql
+-- Keep linked teams in live hospitals. Both directions share the hospital-row lock,
+-- including direct service-role writes and the existing atomic move/revocation RPC.
+set local lock_timeout = '5s';
+set local statement_timeout = '30s';
+
+create function public.work_guard_live_hospital_team() returns trigger
+language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$
+begin
+ perform 1 from public.work_hospitals
+ where id = new.hospital_id and archived_at is null for share;
+ if not found then raise exception 'work_hospital_unavailable'; end if;
+ return new;
+end $$;
+
+create trigger work_hospital_team_live_guard
+ before insert or update of hospital_id on public.work_hospital_teams
+ for each row execute function public.work_guard_live_hospital_team();
+
+create function public.work_guard_hospital_archive() returns trigger
+language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$
+begin
+ -- UPDATE holds the conflicting hospital-row lock before this trigger runs. Re-read
+ -- links here after any wait; an earlier application preflight is not authoritative.
+ if old.archived_at is null and new.archived_at is not null and exists (
+ select 1 from public.work_hospital_teams where hospital_id = old.id
+ ) then raise exception 'work_hospital_has_teams'; end if;
+ return new;
+end $$;
+
+create trigger work_hospital_archive_guard
+ before update of archived_at on public.work_hospitals
+ for each row execute function public.work_guard_hospital_archive();
+
+alter function public.work_guard_live_hospital_team() owner to postgres;
+alter function public.work_guard_hospital_archive() owner to postgres;
+revoke all on function public.work_guard_live_hospital_team(), public.work_guard_hospital_archive()
+ from public, anon, authenticated;
+grant execute on function public.work_guard_live_hospital_team(), public.work_guard_hospital_archive()
+ to service_role;
+
+
+-- supabase/migrations/20261105144251_audit_atomic_document_maintenance.sql
+-- Draft only. Applying/merging this migration requires the owner's own database approval.
+-- Existing source identities only; no backend admission, scanner or supplier authority is minted.
+set local lock_timeout = '5s';
+set local statement_timeout = '60s';
+
+create function public.enqueue_import_document(
+ p_document_id uuid, p_owner_id uuid, p_content_hash text, p_batch_id uuid,
+ p_force boolean, p_max_attempts integer
+) returns jsonb language plpgsql security definer set search_path = '' as $$
+declare d public.documents; j public.ingestion_jobs;
+begin
+ if auth.role() is distinct from 'service_role' then raise exception 'service_role_required' using errcode='42501'; end if;
+ if p_owner_id is null or p_content_hash is null or p_content_hash = '' or p_force is null
+ or p_max_attempts is null or p_max_attempts not between 1 and 100 then
+ raise exception 'invalid_import_queue_request' using errcode='22023';
+ end if;
+ select * into d from public.documents where id=p_document_id for update;
+ if not found or d.owner_id is distinct from p_owner_id or d.content_hash is distinct from p_content_hash then
+ raise exception 'import_document_identity_conflict' using errcode='40001';
+ end if;
+ perform 1 from public.import_batches where id=p_batch_id and owner_id=p_owner_id for share;
+ if not found then raise exception 'import_batch_owner_conflict' using errcode='40001'; end if;
+ select * into j from public.ingestion_jobs where document_id=d.id and status in ('pending','processing') order by id limit 1 for update;
+ if found then return jsonb_build_object('outcome','already_active','job_id',j.id); end if;
+ perform 1 from public.indexing_v3_agent_jobs where document_id=d.id and status in ('pending','processing') for update;
+ if found then raise exception 'maintenance_active_agent_job' using errcode='40001'; end if;
+ if d.status='indexed' and not p_force then return jsonb_build_object('outcome','skipped'); end if;
+ -- Generation A and its counters remain usable until a fenced worker promotes B.
+ insert into public.ingestion_jobs(document_id,batch_id,status,stage,progress,max_attempts)
+ values(d.id,p_batch_id,'pending','queued',0,p_max_attempts) returning * into j;
+ update public.documents set status=case when d.status='indexed' then d.status else 'queued' end,
+ error_message=null where id=d.id;
+ return jsonb_build_object('outcome','enqueued','job_id',j.id);
+end $$;
+alter function public.enqueue_import_document(uuid,uuid,text,uuid,boolean,integer) owner to postgres;
+revoke all on function public.enqueue_import_document(uuid,uuid,text,uuid,boolean,integer) from public,anon,authenticated,service_role;
+grant execute on function public.enqueue_import_document(uuid,uuid,text,uuid,boolean,integer) to service_role;
+
+-- One transaction replaces only the current public projection. Generation-retained evidence is
+-- never a maintenance target. Managed documents continue through their existing fenced worker.
+create function public.replace_document_maintenance_artifacts(
+ p_document_id uuid, p_owner_id uuid, p_expected_updated_at timestamptz,
+ p_expected_generation_id uuid, p_operation text, p_payload jsonb
+) returns jsonb language plpgsql security definer set search_path = '' as $$
+declare
+ d public.documents; item jsonb; patch jsonb; image_ids uuid[]; source_ids uuid[];
+ unit public.document_index_units; field public.document_embedding_fields; image public.document_images; summary_row public.document_summaries;
+ visual_types text[] := array['visual_summary','flowchart_step','diagram_decision','risk_matrix_cell','medication_chart_row','chart_finding','visual_askable_question','table_threshold'];
+begin
+ if auth.role() is distinct from 'service_role' then raise exception 'service_role_required' using errcode='42501'; end if;
+ if p_expected_updated_at is null or p_operation is null or p_operation not in ('labels','enrichment','visual','images','stamp','embedding-fields','registry')
+ or jsonb_typeof(p_payload) is distinct from 'object' then raise exception 'invalid_maintenance_request' using errcode='22023'; end if;
+ select * into d from public.documents where id=p_document_id for update;
+ if not found or d.owner_id is distinct from p_owner_id or d.updated_at is distinct from p_expected_updated_at
+ or d.index_generation_id is distinct from p_expected_generation_id or d.status is distinct from 'indexed' then
+ raise exception 'maintenance_snapshot_conflict' using errcode='40001';
+ end if;
+ if d.metadata ?| array['admission_event_id','public_source_version_id','source_catalogue_key','public_source_activation_event_id'] or d.metadata->>'corpus_scope'='australian_public' then raise exception 'managed_evidence_requires_fenced_worker' using errcode='42501'; end if;
+ perform 1 from public.ingestion_jobs where document_id=d.id and status in ('pending','processing') for update;
+ if found then raise exception 'maintenance_active_ingestion_job' using errcode='40001'; end if;
+ perform 1 from public.indexing_v3_agent_jobs where document_id=d.id and status in ('pending','processing') for update;
+ if found then raise exception 'maintenance_active_agent_job' using errcode='40001'; end if;
+ patch:=coalesce(p_payload->'metadata_patch','{}'::jsonb);
+ if p_operation='registry' then
+ if d.file_type is distinct from 'application/vnd.clinical-kb.registry+json'
+ or patch->>'source_kind' is distinct from 'registry_record'
+ or (patch->>'registry_record_kind' in ('service','form','medication','differential')) is not true
+ or d.storage_path is distinct from 'registry://'||(patch->>'registry_record_kind')||'/'||(patch->>'registry_record_id')
+ or d.source_path is distinct from 'registry://'||(patch->>'registry_record_kind')||'/'||(patch->>'registry_record_slug')
+ or jsonb_typeof(patch) is distinct from 'object' or exists(select 1 from jsonb_object_keys(patch) k where k<>all(array[
+ 'source_kind','registry_record_kind','registry_record_subkind','registry_record_id','registry_record_slug','source_title',
+ 'document_status','clinical_validation_status','clinical_validation_evidence','source_role','extraction_quality','publisher','jurisdiction'])) then
+ raise exception 'invalid_registry_maintenance_scope' using errcode='22023'; end if;
+ else
+ if jsonb_typeof(patch) is distinct from 'object' or exists(select 1 from jsonb_object_keys(patch) k
+ where k<>all(array['rag_enrichment_version','rag_indexing_version','rag_memory_version','rag_enrichment_updated_at',
+ 'generated_label_count','organization_profile','organization_profile_version','coverage_profile',
+ 'enrichment_prompt_strategy','enrichment_prompt_chunk_ids','version_stamped_at',
+ 'searchable_image_count','skipped_image_count','image_enriched_at'])) then
+ raise exception 'invalid_maintenance_metadata_keys' using errcode='22023';
+ end if;
+ end if;
+
+ if p_operation in ('labels','enrichment') then
+ if jsonb_typeof(p_payload->'labels') is distinct from 'array' or jsonb_array_length(p_payload->'labels')>5000 then
+ raise exception 'invalid_maintenance_labels' using errcode='22023'; end if;
+ for item in select value from jsonb_array_elements(p_payload->'labels') loop
+ if jsonb_typeof(item) is distinct from 'object' or nullif(btrim(item->>'label'),'') is null
+ or jsonb_typeof(item->'metadata') is distinct from 'object' then raise exception 'invalid_maintenance_label' using errcode='22023'; end if;
+ end loop;
+ -- The delete and all replacement inserts share this transaction; insert failure restores A.
+ delete from public.document_labels where document_id=d.id and source='generated';
+ insert into public.document_labels(document_id,owner_id,label,label_type,source,confidence,metadata)
+ select d.id,d.owner_id,r.label,r.label_type,'generated',r.confidence,r.metadata
+ from jsonb_to_recordset(p_payload->'labels') r(label text,label_type text,confidence real,metadata jsonb);
+ end if;
+ if p_operation='registry' then
+ if jsonb_typeof(p_payload->'label_ids_to_delete') is distinct from 'array' or jsonb_typeof(p_payload->'labels_to_upsert') is distinct from 'array'
+ or jsonb_array_length(p_payload->'label_ids_to_delete')>5000 or jsonb_array_length(p_payload->'labels_to_upsert')>5000 then
+ raise exception 'invalid_registry_label_population' using errcode='22023'; end if;
+ select coalesce(array_agg(value::uuid),'{}'::uuid[]) into source_ids from jsonb_array_elements_text(p_payload->'label_ids_to_delete');
+ if exists(select 1 from unnest(source_ids) id where not exists(select 1 from public.document_labels l where l.id=id
+ and l.document_id=d.id and l.owner_id is not distinct from d.owner_id and l.source='generated' and l.label_type in ('site','document_intent'))) then
+ raise exception 'registry_label_snapshot_conflict' using errcode='40001'; end if;
+ for item in select value from jsonb_array_elements(p_payload->'labels_to_upsert') loop
+ if item->>'label_type' is distinct from 'document_intent' or item->>'label' is distinct from
+ (case patch->>'registry_record_kind' when 'service' then 'operational-process' when 'form' then 'documentation-requirement'
+ when 'medication' then 'medication-instruction' when 'differential' then 'decision-support' end)
+ or jsonb_typeof(item->'metadata') is distinct from 'object' then
+ raise exception 'invalid_registry_intent_label' using errcode='22023'; end if;
+ end loop;
+ delete from public.document_labels where document_id=d.id and owner_id is not distinct from d.owner_id
+ and source='generated' and id=any(source_ids);
+ insert into public.document_labels(document_id,owner_id,label,label_type,source,confidence,metadata)
+ select d.id,d.owner_id,r.label,'document_intent','generated',r.confidence,r.metadata
+ from jsonb_to_recordset(p_payload->'labels_to_upsert') r(label text,confidence real,metadata jsonb)
+ on conflict(document_id,label_type,label,source) do update set confidence=excluded.confidence,metadata=excluded.metadata;
+ end if;
+
+ if p_operation='enrichment' then
+ item:=p_payload->'summary';
+ if jsonb_typeof(item) is distinct from 'object' or nullif(btrim(item->>'summary'),'') is null
+ or jsonb_typeof(item->'metadata') is distinct from 'object'
+ or jsonb_typeof(item->'source_chunk_ids') is distinct from 'array'
+ or jsonb_typeof(item->'source_image_ids') is distinct from 'array' then
+ raise exception 'invalid_maintenance_summary' using errcode='22023'; end if;
+ select coalesce(array_agg(value::uuid),'{}'::uuid[]) into source_ids from jsonb_array_elements_text(item->'source_chunk_ids');
+ if exists(select 1 from unnest(source_ids) id where not exists(select 1 from public.document_chunks c
+ where c.id=id and c.document_id=d.id and c.index_generation_id is not distinct from p_expected_generation_id)) then
+ raise exception 'maintenance_source_generation_conflict' using errcode='40001'; end if;
+ select coalesce(array_agg(value::uuid),'{}'::uuid[]) into image_ids from jsonb_array_elements_text(item->'source_image_ids');
+ if exists(select 1 from unnest(image_ids) id where not exists(select 1 from public.document_images i
+ where i.id=id and i.document_id=d.id and i.index_generation_id is not distinct from p_expected_generation_id)) then
+ raise exception 'maintenance_source_generation_conflict' using errcode='40001'; end if;
+ insert into public.document_summaries(document_id,owner_id,summary,clinical_specifics,source_chunk_ids,source_image_ids,model,metadata,generated_at,updated_at)
+ values(d.id,d.owner_id,item->>'summary',coalesce(item->'clinical_specifics','{}'::jsonb),source_ids,image_ids,item->>'model',item->'metadata',(item->>'generated_at')::timestamptz,(item->>'updated_at')::timestamptz)
+ on conflict(document_id) do update set owner_id=excluded.owner_id,summary=excluded.summary,clinical_specifics=excluded.clinical_specifics,
+ source_chunk_ids=excluded.source_chunk_ids,source_image_ids=excluded.source_image_ids,model=excluded.model,metadata=excluded.metadata,
+ generated_at=excluded.generated_at,updated_at=excluded.updated_at returning * into summary_row;
+ end if;
+
+ if p_operation='visual' then
+ if jsonb_typeof(p_payload->'image_ids') is distinct from 'array' or jsonb_typeof(p_payload->'units') is distinct from 'array'
+ or jsonb_array_length(p_payload->'image_ids')>1000 or jsonb_array_length(p_payload->'units')>10000 then
+ raise exception 'invalid_maintenance_visual_population' using errcode='22023'; end if;
+ select coalesce(array_agg(value::uuid),'{}'::uuid[]) into image_ids from jsonb_array_elements_text(p_payload->'image_ids');
+ if exists(select 1 from unnest(image_ids) id where not exists(select 1 from public.document_images i
+ where i.id=id and i.document_id=d.id and i.index_generation_id is not distinct from p_expected_generation_id)) then
+ raise exception 'maintenance_source_generation_conflict' using errcode='40001'; end if;
+ -- Validate the complete packet before replacing anything. Row/table constraints are still authoritative.
+ for item in select value from jsonb_array_elements(p_payload->'units') loop
+ unit:=jsonb_populate_record(null::public.document_index_units,item);
+ if unit.document_id is distinct from d.id or unit.owner_id is distinct from d.owner_id
+ or unit.index_generation_id is distinct from p_expected_generation_id or unit.source_image_id is null
+ or not(unit.source_image_id=any(image_ids)) or unit.unit_type is null or not(unit.unit_type=any(visual_types)) then
+ raise exception 'invalid_maintenance_visual_identity' using errcode='22023'; end if;
+ if unit.source_chunk_id is not null and not exists(select 1 from public.document_chunks c where c.id=unit.source_chunk_id
+ and c.document_id=d.id and c.index_generation_id is not distinct from p_expected_generation_id) then
+ raise exception 'maintenance_source_generation_conflict' using errcode='40001'; end if;
+ end loop;
+ delete from public.document_index_units where document_id=d.id and index_generation_id is not distinct from p_expected_generation_id
+ and source_image_id=any(image_ids) and unit_type=any(visual_types);
+ for item in select value from jsonb_array_elements(p_payload->'units') loop
+ unit:=jsonb_populate_record(null::public.document_index_units,item);
+ insert into public.document_index_units(owner_id,document_id,unit_type,source_chunk_id,source_image_id,page_start,page_end,
+ heading_path,title,content,normalized_terms,source_span,quality_score,extraction_mode,index_generation_id,embedding,metadata)
+ values(d.owner_id,d.id,unit.unit_type,unit.source_chunk_id,unit.source_image_id,unit.page_start,unit.page_end,
+ coalesce(unit.heading_path,'{}'),unit.title,unit.content,coalesce(unit.normalized_terms,'{}'),unit.source_span,
+ coalesce(unit.quality_score,0.7),coalesce(unit.extraction_mode,'deterministic'),p_expected_generation_id,unit.embedding,coalesce(unit.metadata,'{}'));
+ end loop;
+ end if;
+
+ if p_operation in ('visual','images') then
+ if jsonb_typeof(p_payload->'image_patches') is distinct from 'array' or jsonb_array_length(p_payload->'image_patches')>1000 then
+ raise exception 'invalid_maintenance_image_population' using errcode='22023'; end if;
+ for item in select value from jsonb_array_elements(p_payload->'image_patches') loop
+ select * into image from public.document_images i where i.id=(item->>'id')::uuid and i.document_id=d.id
+ and i.index_generation_id is not distinct from p_expected_generation_id for update;
+ if not found or (p_operation='visual' and not(image.id=any(image_ids))) then
+ raise exception 'maintenance_source_generation_conflict' using errcode='40001'; end if;
+ if jsonb_typeof(item) is distinct from 'object' or exists(select 1 from jsonb_object_keys(item) k where k<>all(array[
+ 'id','metadata','caption','image_type','searchable','clinical_relevance_score','skip_reason','image_hash','perceptual_hash','labels']))
+ or (item ? 'metadata' and jsonb_typeof(item->'metadata') is distinct from 'object') then
+ raise exception 'invalid_maintenance_image_patch' using errcode='22023'; end if;
+ image:=jsonb_populate_record(image,item-'id'-'metadata');
+ update public.document_images set caption=image.caption,image_type=image.image_type,searchable=image.searchable,
+ clinical_relevance_score=image.clinical_relevance_score,skip_reason=image.skip_reason,image_hash=image.image_hash,
+ perceptual_hash=image.perceptual_hash,labels=image.labels,metadata=coalesce(metadata,'{}')||coalesce(item->'metadata','{}')
+ where id=image.id;
+ end loop;
+ end if;
+ if p_operation='embedding-fields' then
+ if jsonb_typeof(p_payload->'fields') is distinct from 'array' or jsonb_array_length(p_payload->'fields')>1000 then
+ raise exception 'invalid_maintenance_embedding_fields' using errcode='22023'; end if;
+ for item in select value from jsonb_array_elements(p_payload->'fields') loop
+ field:=jsonb_populate_record(null::public.document_embedding_fields,item);
+ if field.document_id is distinct from d.id or field.owner_id is distinct from d.owner_id or field.source_chunk_id is null
+ or field.field_type not in ('document_title','document_summary') or not exists(select 1 from public.document_chunks c
+ where c.id=field.source_chunk_id and c.document_id=d.id and c.index_generation_id is not distinct from p_expected_generation_id) then
+ raise exception 'maintenance_source_generation_conflict' using errcode='40001'; end if;
+ insert into public.document_embedding_fields(owner_id,document_id,source_chunk_id,field_type,content,content_hash,embedding,index_generation_id,metadata)
+ values(d.owner_id,d.id,field.source_chunk_id,field.field_type,field.content,field.content_hash,field.embedding,p_expected_generation_id,
+ coalesce(field.metadata,'{}')||jsonb_build_object('index_generation_id',p_expected_generation_id));
+ end loop;
+ end if;
+
+ if p_operation='stamp' then
+ update public.document_summaries set metadata=coalesce(metadata,'{}')||patch where document_id=d.id;
+ update public.document_labels set metadata=coalesce(metadata,'{}')||patch where document_id=d.id and source='generated';
+ end if;
+ if p_operation='images' and (jsonb_typeof(p_payload->'image_count') is distinct from 'number'
+ or (p_payload->>'image_count')::numeric < 0 or (p_payload->>'image_count')::numeric<>trunc((p_payload->>'image_count')::numeric)) then
+ raise exception 'invalid_maintenance_image_count' using errcode='22023'; end if;
+ update public.documents set metadata=coalesce(metadata,'{}')||patch,
+ image_count=case when p_operation='images' then (p_payload->>'image_count')::integer else image_count end
+ where id=d.id returning * into d;
+ return jsonb_build_object('updated_at',d.updated_at,'metadata',d.metadata,'summary',to_jsonb(summary_row));
+end $$;
+alter function public.replace_document_maintenance_artifacts(uuid,uuid,timestamptz,uuid,text,jsonb) owner to postgres;
+revoke all on function public.replace_document_maintenance_artifacts(uuid,uuid,timestamptz,uuid,text,jsonb) from public,anon,authenticated,service_role;
+grant execute on function public.replace_document_maintenance_artifacts(uuid,uuid,timestamptz,uuid,text,jsonb) to service_role;
+
+-- Snapshot-fenced legacy local producer commit. Current-generation cleanup only; retained
+-- generations remain available to rollback. The fenced worker overload is unchanged.
+create function public.commit_document_maintenance_deep_memory(
+ p_document_id uuid,
+ p_owner_id uuid,
+ p_expected_updated_at timestamptz,
+ p_expected_generation_id uuid,
+ p_chunk_patches jsonb,
+ p_aliases jsonb,
+ p_producer text,
+ p_artifact_generation_id uuid,
+ p_rag_memory_version text,
+ p_document_intelligence_version text,
+ p_section_count integer,
+ p_memory_card_count integer,
+ p_index_unit_counts_by_type jsonb,
+ p_repaired_anchor_count integer default 0
+)
+returns jsonb
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ v_document_metadata jsonb;
+ v_document_updated_at timestamptz;
+ v_chunk_patch jsonb;
+ v_chunk_count integer;
+ v_live_document public.documents;
+ v_document_owner_id uuid;
+ v_committed_index_generation uuid;
+ v_total_section_count integer;
+ v_total_memory_card_count integer;
+ v_total_index_unit_count integer;
+ v_section_count integer;
+ v_memory_card_count integer;
+ v_index_unit_count integer;
+ v_expected_index_unit_count integer;
+ v_index_unit_counts_by_type jsonb;
+begin
+ if auth.role() is distinct from 'service_role' then raise exception 'service_role_required' using errcode='42501'; end if;
+ if p_producer is distinct from 'local-worker' then raise exception 'maintenance_local_producer_required' using errcode='42501'; end if;
+ if nullif(btrim(p_producer), '') is null
+ or nullif(btrim(p_rag_memory_version), '') is null
+ or nullif(btrim(p_document_intelligence_version), '') is null
+ then
+ raise exception 'Deep-memory producer must be non-empty.' using errcode = '22023';
+ end if;
+ if p_artifact_generation_id is null then
+ raise exception 'Deep-memory artifact generation must be set.' using errcode = '22023';
+ end if;
+ if p_section_count is null or p_memory_card_count is null or p_repaired_anchor_count is null
+ or p_section_count < 0 or p_memory_card_count < 0 or p_repaired_anchor_count < 0
+ then
+ raise exception 'Deep-memory counts cannot be negative.' using errcode = '22023';
+ end if;
+ if p_index_unit_counts_by_type is null or jsonb_typeof(p_index_unit_counts_by_type) <> 'object' then
+ raise exception 'Deep-memory index-unit counts must be an object.' using errcode = '22023';
+ end if;
+ if exists (
+ select 1
+ from jsonb_each(p_index_unit_counts_by_type) item
+ where jsonb_typeof(item.value) <> 'number'
+ or (item.value #>> '{}')::numeric < 0
+ or trunc((item.value #>> '{}')::numeric) <> (item.value #>> '{}')::numeric
+ or (item.value #>> '{}')::numeric > 2147483647
+ ) then
+ raise exception 'Deep-memory index-unit counts must be nonnegative integers.' using errcode = '22023';
+ end if;
+
+ select coalesce(d.metadata, '{}'::jsonb), d.owner_id, d.updated_at
+ into v_document_metadata, v_document_owner_id, v_document_updated_at
+ from public.documents d
+ where d.id = p_document_id
+ for update;
+
+ if not found then
+ raise exception 'Document % does not exist.', p_document_id using errcode = 'P0002';
+ end if;
+
+ perform 1 from public.documents where id=p_document_id and status='indexed';
+ if not found then raise exception 'maintenance_snapshot_conflict' using errcode='40001'; end if;
+ if p_expected_updated_at is null or v_document_updated_at is distinct from p_expected_updated_at
+ or v_document_owner_id is distinct from p_owner_id
+ or (nullif(v_document_metadata->>'index_generation_id',''))::uuid is distinct from p_expected_generation_id then
+ raise exception 'maintenance_snapshot_conflict' using errcode='40001';
+ end if;
+ if v_document_metadata ?| array['admission_event_id','public_source_version_id','source_catalogue_key','public_source_activation_event_id'] or v_document_metadata->>'corpus_scope'='australian_public' then raise exception 'managed_evidence_requires_fenced_worker' using errcode='42501'; end if;
+ perform 1 from public.ingestion_jobs where document_id=p_document_id and status in ('pending','processing') for update;
+ if found then raise exception 'maintenance_active_ingestion_job' using errcode='40001'; end if;
+ perform 1 from public.indexing_v3_agent_jobs where document_id=p_document_id and status in ('pending','processing') for update;
+ if found then raise exception 'maintenance_active_agent_job' using errcode='40001'; end if;
+ -- NULL legacy lineage cannot be assigned to a retained generation without evidence.
+ -- Refuse instead of deleting potential rollback rows or publishing duplicate visible rows.
+ if exists(select 1 from public.document_sections where document_id=p_document_id and index_generation_id is null)
+ or exists(select 1 from public.document_memory_cards where document_id=p_document_id and index_generation_id is null)
+ or exists(select 1 from public.document_index_units where document_id=p_document_id and index_generation_id is null) then
+ raise exception 'legacy_generation_lineage_requires_fenced_reindex' using errcode='23514';
+ end if;
+ if jsonb_typeof(p_chunk_patches) is distinct from 'array' then raise exception 'invalid_maintenance_chunk_patches' using errcode='22023'; end if;
+ for v_chunk_patch in select value from jsonb_array_elements(p_chunk_patches) loop
+ if jsonb_typeof(v_chunk_patch) is distinct from 'object' or jsonb_typeof(v_chunk_patch->'metadata') is distinct from 'object'
+ or exists(select 1 from jsonb_object_keys(v_chunk_patch->'metadata') k where k<>all(array[
+ 'anchor_id','anchor_repaired_by','rag_indexing_version','rag_memory_version','rag_memory_updated_at','document_intelligence_version'])) then
+ raise exception 'invalid_maintenance_chunk_patch' using errcode='22023'; end if;
+ update public.document_chunks set
+ anchor_id=coalesce(anchor_id,v_chunk_patch->>'anchor_id'),
+ metadata=coalesce(metadata,'{}')||(v_chunk_patch->'metadata')
+ where id=(v_chunk_patch->>'id')::uuid and document_id=p_document_id
+ and index_generation_id is not distinct from p_expected_generation_id;
+ get diagnostics v_chunk_count=row_count;
+ if v_chunk_count<>1 then raise exception 'maintenance_source_generation_conflict' using errcode='40001'; end if;
+ end loop;
+
+ begin
+ v_committed_index_generation := nullif(v_document_metadata->>'index_generation_id', '')::uuid;
+ exception when invalid_text_representation then
+ raise exception 'Document % has an invalid committed index generation.', p_document_id using errcode = '22023';
+ end;
+ if v_committed_index_generation is null then
+ raise exception 'Document % has no committed index generation.', p_document_id using errcode = '23514';
+ end if;
+ if v_committed_index_generation = p_artifact_generation_id then
+ raise exception 'Staged deep-memory generation must differ from the committed index generation.' using errcode = '23514';
+ end if;
+
+ -- A generation UUID is a single-producer staging boundary. Reject collisions
+ -- rather than interpreting another producer's rows as part of this commit.
+ if exists (
+ select 1 from public.document_sections
+ where document_id = p_document_id
+ and artifact_generation_id = p_artifact_generation_id
+ and producer is distinct from p_producer
+ ) or exists (
+ select 1 from public.document_memory_cards
+ where document_id = p_document_id
+ and artifact_generation_id = p_artifact_generation_id
+ and producer is distinct from p_producer
+ ) or exists (
+ select 1 from public.document_index_units
+ where document_id = p_document_id
+ and artifact_generation_id = p_artifact_generation_id
+ and producer is distinct from p_producer
+ ) then
+ raise exception 'Deep-memory artifact generation belongs to another producer.' using errcode = '23514';
+ end if;
+
+ -- Re-check producer evidence inside the transaction. Legacy NULL-generation
+ -- local-worker rows predate explicit producer metadata and are the only
+ -- unlabelled shape eligible for producer-scoped replacement.
+ if exists (
+ select 1 from public.document_sections
+ where document_id = p_document_id
+ and (
+ (producer is not null and nullif(metadata->>'generated_by', '') is distinct from producer)
+ or (producer is null and nullif(metadata->>'generated_by', '') is not null and metadata->>'generated_by' <> p_producer)
+ or (producer is null and artifact_generation_id is not null)
+ or (
+ producer is null
+ and nullif(metadata->>'generated_by', '') is null
+ and not (
+ p_producer = 'local-worker'
+ and artifact_generation_id is null
+ and metadata->>'rag_indexing_version' in ('rag-deep-memory-v1', 'rag-deep-memory-v2')
+ )
+ )
+ )
+ ) or exists (
+ select 1 from public.document_memory_cards
+ where document_id = p_document_id
+ and (
+ (producer is not null and nullif(metadata->>'generated_by', '') is distinct from producer)
+ or (producer is null and nullif(metadata->>'generated_by', '') is not null and metadata->>'generated_by' <> p_producer)
+ or (producer is null and artifact_generation_id is not null)
+ or (
+ producer is null
+ and nullif(metadata->>'generated_by', '') is null
+ and not (p_producer = 'local-worker' and artifact_generation_id is null)
+ )
+ )
+ ) or exists (
+ select 1 from public.document_index_units
+ where document_id = p_document_id
+ and (
+ (producer is not null and nullif(metadata->>'generated_by', '') is distinct from producer)
+ or (producer is null and nullif(metadata->>'generated_by', '') is not null and metadata->>'generated_by' <> p_producer)
+ or (producer is null and artifact_generation_id is not null)
+ or (
+ producer is null
+ and nullif(metadata->>'generated_by', '') is null
+ and not (p_producer = 'local-worker' and artifact_generation_id is null)
+ )
+ )
+ ) then
+ raise exception 'Deep-memory artifact producer evidence is contradictory or ambiguous.' using errcode = '23514';
+ end if;
+
+ select count(*) into v_total_section_count
+ from public.document_sections
+ where document_id = p_document_id
+ and artifact_generation_id = p_artifact_generation_id;
+
+ select count(*) into v_total_memory_card_count
+ from public.document_memory_cards
+ where document_id = p_document_id
+ and artifact_generation_id = p_artifact_generation_id;
+
+ select count(*) into v_total_index_unit_count
+ from public.document_index_units
+ where document_id = p_document_id
+ and artifact_generation_id = p_artifact_generation_id;
+
+ select count(*) into v_section_count
+ from public.document_sections
+ where document_id = p_document_id
+ and producer = p_producer
+ and artifact_generation_id = p_artifact_generation_id
+ and index_generation_id = p_artifact_generation_id
+ and owner_id is not distinct from v_document_owner_id
+ and metadata->>'generated_by' = p_producer
+ and metadata->>'artifact_generation_id' = p_artifact_generation_id::text
+ and metadata->>'index_generation_id' = p_artifact_generation_id::text;
+
+ select count(*) into v_memory_card_count
+ from public.document_memory_cards
+ where document_id = p_document_id
+ and producer = p_producer
+ and artifact_generation_id = p_artifact_generation_id
+ and index_generation_id = p_artifact_generation_id
+ and owner_id is not distinct from v_document_owner_id
+ and metadata->>'generated_by' = p_producer
+ and metadata->>'artifact_generation_id' = p_artifact_generation_id::text
+ and metadata->>'index_generation_id' = p_artifact_generation_id::text;
+
+ select coalesce(sum(unit_count), 0)::integer, coalesce(jsonb_object_agg(unit_type, unit_count), '{}'::jsonb)
+ into v_index_unit_count, v_index_unit_counts_by_type
+ from (
+ select unit_type, count(*)::integer as unit_count
+ from public.document_index_units
+ where document_id = p_document_id
+ and producer = p_producer
+ and artifact_generation_id = p_artifact_generation_id
+ and index_generation_id = p_artifact_generation_id
+ and owner_id is not distinct from v_document_owner_id
+ and metadata->>'generated_by' = p_producer
+ and metadata->>'artifact_generation_id' = p_artifact_generation_id::text
+ and metadata->>'index_generation_id' = p_artifact_generation_id::text
+ group by unit_type
+ ) staged_index_units;
+
+ select coalesce(sum(value::integer), 0)
+ into v_expected_index_unit_count
+ from jsonb_each_text(coalesce(p_index_unit_counts_by_type, '{}'::jsonb));
+
+ if v_total_section_count <> v_section_count
+ or v_total_memory_card_count <> v_memory_card_count
+ or v_total_index_unit_count <> coalesce(v_index_unit_count, 0)
+ or v_section_count <> p_section_count
+ or v_memory_card_count <> p_memory_card_count
+ or coalesce(v_index_unit_count, 0) <> v_expected_index_unit_count
+ or v_index_unit_counts_by_type <> coalesce(p_index_unit_counts_by_type, '{}'::jsonb)
+ then
+ raise exception 'Staged deep-memory artifact counts do not match the commit contract.' using errcode = '23514';
+ end if;
+
+ if exists (
+ select 1
+ from public.document_memory_cards card
+ left join public.document_sections section
+ on section.id = card.section_id
+ and section.document_id = p_document_id
+ and section.producer = p_producer
+ and section.artifact_generation_id = p_artifact_generation_id
+ where card.document_id = p_document_id
+ and card.producer = p_producer
+ and card.artifact_generation_id = p_artifact_generation_id
+ and card.section_id is not null
+ and section.id is null
+ ) then
+ raise exception 'Staged memory cards reference a section outside the staged generation.' using errcode = '23514';
+ end if;
+
+ -- Refuse to null another producer's section reference through ON DELETE SET
+ -- NULL. This keeps the "other producers untouched" guarantee literal.
+ if exists (
+ select 1
+ from public.document_memory_cards card
+ join public.document_sections section on section.id = card.section_id
+ where section.document_id = p_document_id
+ and section.artifact_generation_id is distinct from p_artifact_generation_id
+ and (
+ section.producer = p_producer
+ or (
+ section.producer is null
+ and (
+ section.metadata->>'generated_by' = p_producer
+ or (
+ p_producer = 'local-worker'
+ and nullif(section.metadata->>'generated_by', '') is null
+ and section.metadata->>'rag_indexing_version' in ('rag-deep-memory-v1', 'rag-deep-memory-v2')
+ )
+ )
+ )
+ )
+ and not (
+ (card.producer = p_producer and card.metadata->>'generated_by' = p_producer)
+ or (card.producer is null and card.metadata->>'generated_by' = p_producer)
+ or (
+ p_producer = 'local-worker'
+ and card.producer is null
+ and card.artifact_generation_id is null
+ and nullif(card.metadata->>'generated_by', '') is null
+ )
+ )
+ ) then
+ raise exception 'Another producer references an older section owned by this producer.' using errcode = '23514';
+ end if;
+
+ update public.document_sections
+ set
+ index_generation_id = v_committed_index_generation,
+ metadata = coalesce(metadata, '{}'::jsonb) || jsonb_build_object(
+ 'generated_by', p_producer,
+ 'artifact_generation_id', p_artifact_generation_id,
+ 'index_generation_id', v_committed_index_generation
+ ),
+ updated_at = now()
+ where document_id = p_document_id
+ and producer = p_producer
+ and artifact_generation_id = p_artifact_generation_id;
+
+ update public.document_memory_cards
+ set
+ index_generation_id = v_committed_index_generation,
+ metadata = coalesce(metadata, '{}'::jsonb) || jsonb_build_object(
+ 'generated_by', p_producer,
+ 'artifact_generation_id', p_artifact_generation_id,
+ 'index_generation_id', v_committed_index_generation
+ ),
+ updated_at = now()
+ where document_id = p_document_id
+ and producer = p_producer
+ and artifact_generation_id = p_artifact_generation_id;
+
+ update public.document_index_units
+ set
+ index_generation_id = v_committed_index_generation,
+ metadata = coalesce(metadata, '{}'::jsonb) || jsonb_build_object(
+ 'generated_by', p_producer,
+ 'artifact_generation_id', p_artifact_generation_id,
+ 'index_generation_id', v_committed_index_generation
+ ),
+ updated_at = now()
+ where document_id = p_document_id
+ and producer = p_producer
+ and artifact_generation_id = p_artifact_generation_id;
+
+ delete from public.document_memory_cards
+ where document_id = p_document_id
+ and index_generation_id is not distinct from p_expected_generation_id
+ and artifact_generation_id is distinct from p_artifact_generation_id
+ and (
+ (producer = p_producer and metadata->>'generated_by' = p_producer)
+ or (producer is null and metadata->>'generated_by' = p_producer)
+ or (
+ p_producer = 'local-worker'
+ and producer is null
+ and artifact_generation_id is null
+ and nullif(metadata->>'generated_by', '') is null
+ )
+ );
+
+ delete from public.document_index_units
+ where document_id = p_document_id
+ and index_generation_id is not distinct from p_expected_generation_id
+ and artifact_generation_id is distinct from p_artifact_generation_id
+ and (
+ (producer = p_producer and metadata->>'generated_by' = p_producer)
+ or (producer is null and metadata->>'generated_by' = p_producer)
+ or (
+ p_producer = 'local-worker'
+ and producer is null
+ and artifact_generation_id is null
+ and nullif(metadata->>'generated_by', '') is null
+ )
+ );
+
+ delete from public.document_sections
+ where document_id = p_document_id
+ and index_generation_id is not distinct from p_expected_generation_id
+ and artifact_generation_id is distinct from p_artifact_generation_id
+ and (
+ (producer = p_producer and metadata->>'generated_by' = p_producer)
+ or (
+ producer is null
+ and (
+ metadata->>'generated_by' = p_producer
+ or (
+ p_producer = 'local-worker'
+ and nullif(metadata->>'generated_by', '') is null
+ and metadata->>'rag_indexing_version' in ('rag-deep-memory-v1', 'rag-deep-memory-v2')
+ )
+ )
+ )
+ );
+
+ if jsonb_typeof(p_aliases) is distinct from 'array' then raise exception 'invalid_maintenance_aliases' using errcode='22023'; end if;
+ -- Alias suggestions remain optional, but any accepted insert shares this exact snapshot.
+ begin
+ insert into public.rag_aliases(owner_id,alias,canonical,alias_type,weight,enabled,metadata)
+ select p_owner_id,r.alias,r.canonical,r.alias_type,r.weight,r.enabled,
+ coalesce(r.metadata,'{}')||jsonb_build_object('document_id',p_document_id)
+ from jsonb_to_recordset(p_aliases) r(alias text,canonical text,alias_type text,weight real,enabled boolean,metadata jsonb);
+ exception when check_violation or not_null_violation or unique_violation then null;
+ end;
+
+ -- This is deliberately the final logical mutation. Any error above rolls
+ -- back both activation and producer-scoped cleanup before metadata advertises
+ -- the new deep-memory generation.
+ perform public.apply_document_metadata_patch(
+ p_document_id,
+ jsonb_build_object(
+ 'rag_indexing_version', p_rag_memory_version,
+ 'rag_memory_version', p_rag_memory_version,
+ 'rag_memory_updated_at', now(),
+ 'document_intelligence_version', p_document_intelligence_version,
+ 'document_intelligence_updated_at', now(),
+ 'section_count', p_section_count,
+ 'memory_card_count', p_memory_card_count,
+ 'index_unit_count', v_expected_index_unit_count,
+ 'index_unit_counts_by_type', coalesce(p_index_unit_counts_by_type, '{}'::jsonb),
+ 'repaired_anchor_count', p_repaired_anchor_count,
+ 'deep_memory_generations', jsonb_build_object(p_producer, p_artifact_generation_id)
+ )
+ );
+
+ select * into v_live_document from public.documents where id=p_document_id;
+ return jsonb_build_object(
+ 'updated_at',v_live_document.updated_at, 'metadata',v_live_document.metadata,
+ 'document_id', p_document_id,
+ 'producer', p_producer,
+ 'artifact_generation_id', p_artifact_generation_id,
+ 'index_generation_id', v_committed_index_generation,
+ 'section_count', p_section_count,
+ 'memory_card_count', p_memory_card_count,
+ 'index_unit_count', v_expected_index_unit_count
+ );
+end;
+$$;
+alter function public.commit_document_maintenance_deep_memory(uuid,uuid,timestamptz,uuid,jsonb,jsonb,text,uuid,text,text,integer,integer,jsonb,integer) owner to postgres;
+revoke all on function public.commit_document_maintenance_deep_memory(uuid,uuid,timestamptz,uuid,jsonb,jsonb,text,uuid,text,text,integer,integer,jsonb,integer) from public,anon,authenticated,service_role;
+grant execute on function public.commit_document_maintenance_deep_memory(uuid,uuid,timestamptz,uuid,jsonb,jsonb,text,uuid,text,text,integer,integer,jsonb,integer) to service_role;
+
+-- No active application/script caller needs either destructive legacy entry point. The worker
+-- uses the separate IngestionFence overload; maintenance now uses the snapshot-fenced commit.
+-- Keep definitions/history intact while closing these obsolete execution paths to app roles.
+revoke all on function public.reset_document_index(uuid) from public,anon,authenticated,service_role;
+revoke all on function public.commit_document_deep_memory_generation(uuid,text,uuid,text,text,integer,integer,jsonb,integer)
+ from public,anon,authenticated,service_role;
+
+
+-- supabase/migrations/20261105144902_audit_teaching_link_and_booking_lifecycle.sql
+-- Enforce the existing twenty-active-link ceiling atomically, including older application writers.
+-- A versioned create authority makes newer apps refuse schemas lacking this contract.
+set local lock_timeout = '5s';
+set local statement_timeout = '30s';
+
+create function public.teaching_guard_epa_pending_limit() returns trigger
+language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$
+declare v_now timestamptz;
+begin
+ if new.status <> 'pending' or new.expires_at <= clock_timestamp() then return new; end if;
+ perform pg_advisory_xact_lock(hashtextextended(new.registrar_id::text, 5112026));
+ v_now := clock_timestamp();
+ if new.expires_at <= v_now then return new; end if;
+ -- A pending update that does not consume another slot may preserve legacy over-limit rows.
+ -- Re-check expiry after acquiring the lock so a waiting renewal cannot revive an uncounted slot.
+ if tg_op = 'UPDATE' and old.registrar_id = new.registrar_id
+ and old.status = 'pending' and old.expires_at > v_now then return new; end if;
+ if (select count(*) from public.teaching_epa_magic_links
+ where registrar_id = new.registrar_id and status = 'pending' and expires_at > v_now
+ and id <> new.id) >= 20 then
+ raise exception 'magic_link_limit_reached';
+ end if;
+ return new;
+end $$;
+
+create trigger teaching_epa_pending_limit_guard
+ before insert or update of registrar_id, status, expires_at on public.teaching_epa_magic_links
+ for each row execute function public.teaching_guard_epa_pending_limit();
+
+create function public.teaching_create_epa_magic_link_v1(p_registrar_id uuid, p_link jsonb)
+returns jsonb language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$
+declare v_link public.teaching_epa_magic_links;
+begin
+ if p_registrar_id is null or jsonb_typeof(p_link) is distinct from 'object' then
+ raise exception 'magic_link_invalid_request';
+ end if;
+ insert into public.teaching_epa_magic_links(
+ registrar_id, token_hash, epa_number, trainee_name, supervisor_name, supervisor_role,
+ clinical_setting, status, expires_at, updated_at
+ ) values (
+ p_registrar_id, p_link->>'token_hash', (p_link->>'epa_number')::smallint,
+ p_link->>'trainee_name', p_link->>'supervisor_name', p_link->>'supervisor_role',
+ p_link->>'clinical_setting', 'pending', (p_link->>'expires_at')::timestamptz,
+ coalesce((p_link->>'updated_at')::timestamptz, now())
+ ) returning * into v_link;
+ return to_jsonb(v_link);
+end $$;
+
+alter function public.teaching_guard_epa_pending_limit() owner to postgres;
+alter function public.teaching_create_epa_magic_link_v1(uuid, jsonb) owner to postgres;
+revoke all on function public.teaching_guard_epa_pending_limit(), public.teaching_create_epa_magic_link_v1(uuid, jsonb)
+ from public, anon, authenticated;
+grant execute on function public.teaching_guard_epa_pending_limit(), public.teaching_create_epa_magic_link_v1(uuid, jsonb)
+ to service_role;
+
+-- Bound the result of overlap checks without comparing raw JSON timestamp text. Existing
+-- cancellation and earlier-created-slot priority semantics remain the same as the app.
+create function public.teaching_find_supervisor_overlap_v1(
+ p_supervisor_id uuid, p_starts_at timestamptz, p_ends_at timestamptz, p_exclude_id uuid,
+ p_before_created_at timestamptz default null, p_before_id uuid default null
+) returns setof public.registrar_assessments
+language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$
+begin
+ if p_supervisor_id is null or p_starts_at is null or p_ends_at is null or p_ends_at <= p_starts_at
+ or p_exclude_id is null or ((p_before_created_at is null) <> (p_before_id is null)) then
+ raise exception 'invalid_booking_window';
+ end if;
+ return query select r.* from public.registrar_assessments r
+ where r.assessor_id = p_supervisor_id and r.category = 'supervisor_review'
+ and r.ratings->>'kind' = 'supervisor_booking' and r.id <> p_exclude_id
+ and (case when r.ratings->>'bookingState' in ('open', 'booked', 'completed', 'cancelled')
+ then r.ratings->>'bookingState' when r.status = 'archived' then 'cancelled' else 'active' end) <> 'cancelled'
+ and (p_before_created_at is null or (r.created_at, r.id) < (p_before_created_at, p_before_id))
+ -- CASE also fences casts from unrelated assessment JSON if the planner reorders filters.
+ and (case when r.assessor_id = p_supervisor_id and r.category = 'supervisor_review'
+ and r.ratings->>'kind' = 'supervisor_booking' then
+ case when jsonb_typeof(r.ratings->'startsAt') = 'string'
+ then (r.ratings->>'startsAt')::timestamptz else r.created_at end
+ else null end) < p_ends_at
+ and (case when r.assessor_id = p_supervisor_id and r.category = 'supervisor_review'
+ and r.ratings->>'kind' = 'supervisor_booking' then
+ case when jsonb_typeof(r.ratings->'endsAt') = 'string'
+ then (r.ratings->>'endsAt')::timestamptz else r.updated_at end
+ else null end) > p_starts_at
+ order by r.created_at, r.id limit 1;
+end $$;
+alter function public.teaching_find_supervisor_overlap_v1(uuid, timestamptz, timestamptz, uuid, timestamptz, uuid) owner to postgres;
+revoke all on function public.teaching_find_supervisor_overlap_v1(uuid, timestamptz, timestamptz, uuid, timestamptz, uuid)
+ from public, anon, authenticated;
+grant execute on function public.teaching_find_supervisor_overlap_v1(uuid, timestamptz, timestamptz, uuid, timestamptz, uuid)
+ to service_role;
+
+
+-- supabase/migrations/20261105150056_audit_cpd_entry_concurrency.sql
+-- Full manual CPD edits and amendments replace exactly the stored content their editor read.
+-- Strengthen the legacy RPCs too: old clients without expectedVersion fail closed on edits.
+-- Creates retain request-id idempotency; intentional Teaching create/restore remains separate.
+-- Apply before the app release: new clients use versioned RPCs and never fall back.
+
+set local lock_timeout = '5s';
+set local statement_timeout = '30s';
+
+-- Called only after the writer has locked the owner-scoped entry FOR UPDATE. Allocation
+-- guards take FOR SHARE on that entry, so allocations cannot change across this comparison.
+-- VOLATILE gives this lookup a fresh snapshot after a lock wait. Include copied/archive
+-- state so narrow alternate mutations also invalidate a stale full-record editor.
+create function public.cme_entry_edit_version(p_owner_id uuid, p_entry_id uuid)
+returns jsonb
+language sql volatile security invoker set search_path = public, pg_catalog, pg_temp as $$
+ select jsonb_build_object(
+ 'id',e.id,'date',e.activity_date,'title',e.title,'reflection',e.reflection,
+ 'costCents',e.cost_cents,'routineId',e.routine_id,'documentId',e.document_id,
+ 'sourceUrl',e.source_url,'buckets',to_jsonb(e.buckets),
+ 'formalPeerReviewHours',coalesce(e.formal_peer_review_hours,0),
+ 'transcribed',e.transcribed_at is not null,'archived',e.archived_at is not null,
+ 'allocations',(
+ select coalesce(jsonb_agg(jsonb_build_object('category',a.category,'hours',a.hours) order by a.category),'[]'::jsonb)
+ from public.cme_allocations a where a.owner_id=p_owner_id and a.entry_id=e.id
+ )
+ ) from public.cme_entries e where e.owner_id=p_owner_id and e.id=p_entry_id
+$$;
+
+create or replace function public.cme_save_entry(p_owner_id uuid,p_year_id uuid,p_entry_id uuid,p_entry jsonb,p_create boolean,p_request_id uuid default null) returns jsonb
+language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$
+declare
+ v_year public.cme_years; v_existing public.cme_entries; v_saved public.cme_entries;
+ v_routine public.cme_routines; v_alloc jsonb; v_review numeric; v_total numeric; v_count integer;
+begin
+ if p_owner_id is null or p_year_id is null or p_entry_id is null or p_create is null
+ or jsonb_typeof(p_entry) is distinct from 'object' then raise exception 'cme_invalid_request'; end if;
+ perform pg_advisory_xact_lock(hashtextextended(p_owner_id::text, 23092026));
+ if p_create and p_request_id is not null then
+ select * into v_existing from public.cme_entries where owner_id=p_owner_id and request_id=p_request_id;
+ if found then
+ if v_existing.request_payload is distinct from p_entry then raise exception 'cme_retry_conflict'; end if;
+ return to_jsonb(v_existing) || jsonb_build_object('cme_allocations',(select coalesce(jsonb_agg(jsonb_build_object('category',category,'hours',hours)),'[]') from public.cme_allocations where owner_id=p_owner_id and entry_id=v_existing.id));
+ end if;
+ end if;
+ select * into v_year from public.cme_years where owner_id=p_owner_id and id=p_year_id for update;
+ if not found then raise exception 'cme_year_not_confirmed'; end if;
+ if v_year.closed_at is not null then raise exception 'cme_year_closed'; end if;
+ if extract(year from (p_entry->>'date')::date) <> v_year.year then raise exception 'cme_invalid_request'; end if;
+ if not p_create then
+ select * into v_existing from public.cme_entries where owner_id=p_owner_id and id=p_entry_id for update;
+ if not found then raise exception 'cme_entry_not_found'; end if;
+ if v_existing.archived_at is not null then raise exception 'cme_entry_archived'; end if;
+ if p_entry->'expectedVersion' is distinct from public.cme_entry_edit_version(p_owner_id,p_entry_id) then raise exception 'cme_entry_conflict'; end if;
+ if exists(select 1 from public.cme_years where owner_id=p_owner_id and id=v_existing.year_id and closed_at is not null) then raise exception 'cme_year_closed'; end if;
+ end if;
+ if jsonb_typeof(p_entry->'allocations') is distinct from 'array' then raise exception 'cme_invalid_request'; end if;
+ select count(*),coalesce(sum((a->>'hours')::numeric),0),coalesce(sum((a->>'hours')::numeric) filter(where a->>'category'='reviewing'),0)
+ into v_count,v_total,v_review from jsonb_array_elements(p_entry->'allocations') a;
+ if v_count not between 1 and 3 or v_total > 24 or coalesce((p_entry->>'formalPeerReviewHours')::numeric,0) not between 0 and v_review then raise exception 'cme_invalid_request'; end if;
+ if p_entry->>'documentId' is not null and not exists(select 1 from public.documents where owner_id=p_owner_id and id=(p_entry->>'documentId')::uuid) then raise exception 'cme_invalid_link'; end if;
+ if p_entry->>'routineId' is not null then
+ select * into v_routine from public.cme_routines where owner_id=p_owner_id and id=(p_entry->>'routineId')::uuid for update;
+ if not found or (p_create and v_routine.archived_at is not null) then raise exception 'cme_invalid_link'; end if;
+ end if;
+ if p_create then
+ insert into public.cme_entries(id,owner_id,year_id,activity_date,title,reflection,cost_cents,routine_id,document_id,buckets,formal_peer_review_hours,request_id,request_payload,source_url)
+ values(p_entry_id,p_owner_id,p_year_id,(p_entry->>'date')::date,p_entry->>'title',p_entry->>'reflection',(p_entry->>'costCents')::integer,(p_entry->>'routineId')::uuid,(p_entry->>'documentId')::uuid,array(select jsonb_array_elements_text(p_entry->'buckets')),coalesce((p_entry->>'formalPeerReviewHours')::numeric,0),p_request_id,p_entry,p_entry->>'sourceUrl')
+ returning * into v_saved;
+ else
+ update public.cme_entries set year_id=p_year_id,activity_date=(p_entry->>'date')::date,title=p_entry->>'title',reflection=p_entry->>'reflection',cost_cents=(p_entry->>'costCents')::integer,routine_id=(p_entry->>'routineId')::uuid,document_id=(p_entry->>'documentId')::uuid,buckets=array(select jsonb_array_elements_text(p_entry->'buckets')),formal_peer_review_hours=coalesce((p_entry->>'formalPeerReviewHours')::numeric,0),source_url=p_entry->>'sourceUrl'
+ where owner_id=p_owner_id and id=p_entry_id returning * into v_saved;
+ delete from public.cme_allocations where owner_id=p_owner_id and entry_id=p_entry_id;
+ end if;
+ for v_alloc in select value from jsonb_array_elements(p_entry->'allocations') loop
+ insert into public.cme_allocations(owner_id,entry_id,category,hours) values(p_owner_id,p_entry_id,v_alloc->>'category',(v_alloc->>'hours')::numeric);
+ end loop;
+ -- A routine advances only after the complete entry is saved, in this same
+ -- transaction. An idempotent retry returns above and never advances twice.
+ if p_create and v_routine.id is not null and v_routine.next_due is not null and v_routine.next_due <= (p_entry->>'date')::date then
+ update public.cme_routines set next_due=((p_entry->>'date')::date + case v_routine.cadence when 'weekly' then interval '7 days' when 'monthly' then interval '1 month' else interval '3 months' end)::date where owner_id=p_owner_id and id=v_routine.id;
+ end if;
+ return to_jsonb(v_saved)||jsonb_build_object('cme_allocations',p_entry->'allocations');
+end $$;
+
+create or replace function public.cme_amend_closed_entry(p_owner_id uuid, p_entry_id uuid, p_entry jsonb, p_reason text)
+returns jsonb
+language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$
+declare
+ v_year public.cme_years; v_existing public.cme_entries; v_saved public.cme_entries; v_routine public.cme_routines;
+ v_alloc jsonb; v_review numeric; v_total numeric; v_count integer; v_before jsonb;
+ v_reason text := btrim(coalesce(p_reason, ''));
+begin
+ if p_owner_id is null or p_entry_id is null or jsonb_typeof(p_entry) is distinct from 'object' then
+ raise exception 'cme_invalid_request';
+ end if;
+ if char_length(v_reason) not between 3 and 1000 then raise exception 'cme_amendment_reason_invalid'; end if;
+ perform pg_advisory_xact_lock(hashtextextended(p_owner_id::text, 23092026));
+ select * into v_existing from public.cme_entries where owner_id=p_owner_id and id=p_entry_id for update;
+ if not found then raise exception 'cme_entry_not_found'; end if;
+ if v_existing.archived_at is not null then raise exception 'cme_entry_archived'; end if;
+ if p_entry->'expectedVersion' is distinct from public.cme_entry_edit_version(p_owner_id,p_entry_id) then raise exception 'cme_entry_conflict'; end if;
+ select * into v_year from public.cme_years where owner_id=p_owner_id and id=v_existing.year_id for update;
+ if not found then raise exception 'cme_year_not_confirmed'; end if;
+ if v_year.closed_at is null then raise exception 'cme_year_open'; end if;
+ if extract(year from (p_entry->>'date')::date) <> v_year.year then raise exception 'cme_invalid_request'; end if;
+ if jsonb_typeof(p_entry->'allocations') is distinct from 'array' then raise exception 'cme_invalid_request'; end if;
+ select count(*),coalesce(sum((a->>'hours')::numeric),0),coalesce(sum((a->>'hours')::numeric) filter(where a->>'category'='reviewing'),0)
+ into v_count,v_total,v_review from jsonb_array_elements(p_entry->'allocations') a;
+ if v_count not between 1 and 3 or v_total > 24 or coalesce((p_entry->>'formalPeerReviewHours')::numeric,0) not between 0 and v_review then raise exception 'cme_invalid_request'; end if;
+ if p_entry->>'documentId' is not null and not exists(select 1 from public.documents where owner_id=p_owner_id and id=(p_entry->>'documentId')::uuid) then raise exception 'cme_invalid_link'; end if;
+ if p_entry->>'routineId' is not null then
+ select * into v_routine from public.cme_routines where owner_id=p_owner_id and id=(p_entry->>'routineId')::uuid;
+ if not found then raise exception 'cme_invalid_link'; end if;
+ end if;
+
+ v_before := jsonb_build_object(
+ 'date', v_existing.activity_date, 'title', v_existing.title, 'reflection', v_existing.reflection,
+ 'costCents', v_existing.cost_cents, 'routineId', v_existing.routine_id, 'documentId', v_existing.document_id,
+ 'sourceUrl', v_existing.source_url, 'buckets', to_jsonb(v_existing.buckets),
+ 'formalPeerReviewHours', v_existing.formal_peer_review_hours,
+ 'allocations', (
+ select coalesce(jsonb_agg(jsonb_build_object('category', category, 'hours', hours) order by category), '[]'::jsonb)
+ from public.cme_allocations where owner_id=p_owner_id and entry_id=p_entry_id
+ )
+ );
+ insert into public.cme_year_amendments(owner_id, year_id, entry_id, reason, before, after)
+ values (
+ p_owner_id, v_year.id, p_entry_id, v_reason, v_before,
+ jsonb_build_object(
+ 'date', p_entry->'date', 'title', p_entry->'title', 'reflection', p_entry->'reflection',
+ 'costCents', p_entry->'costCents', 'routineId', p_entry->'routineId', 'documentId', p_entry->'documentId',
+ 'sourceUrl', p_entry->'sourceUrl', 'buckets', p_entry->'buckets',
+ 'formalPeerReviewHours', coalesce(p_entry->'formalPeerReviewHours', '0'::jsonb),
+ 'allocations', p_entry->'allocations'
+ )
+ );
+
+ update public.cme_entries set activity_date=(p_entry->>'date')::date,title=p_entry->>'title',reflection=p_entry->>'reflection',cost_cents=(p_entry->>'costCents')::integer,routine_id=(p_entry->>'routineId')::uuid,document_id=(p_entry->>'documentId')::uuid,buckets=array(select jsonb_array_elements_text(p_entry->'buckets')),formal_peer_review_hours=coalesce((p_entry->>'formalPeerReviewHours')::numeric,0),source_url=p_entry->>'sourceUrl'
+ where owner_id=p_owner_id and id=p_entry_id returning * into v_saved;
+ delete from public.cme_allocations where owner_id=p_owner_id and entry_id=p_entry_id;
+ for v_alloc in select value from jsonb_array_elements(p_entry->'allocations') loop
+ insert into public.cme_allocations(owner_id,entry_id,category,hours) values(p_owner_id,p_entry_id,v_alloc->>'category',(v_alloc->>'hours')::numeric);
+ end loop;
+ return to_jsonb(v_saved)||jsonb_build_object('cme_allocations',p_entry->'allocations');
+end $$;
+
+-- Versioned names prevent new app/old database overlap from silently ignoring the guard.
+create function public.cme_save_entry_v2(p_owner_id uuid,p_year_id uuid,p_entry_id uuid,p_entry jsonb,p_create boolean,p_request_id uuid default null)
+returns jsonb language sql volatile security invoker set search_path = public, pg_catalog, pg_temp as $$
+ select public.cme_save_entry(p_owner_id,p_year_id,p_entry_id,p_entry,p_create,p_request_id)
+$$;
+create function public.cme_amend_closed_entry_v2(p_owner_id uuid,p_entry_id uuid,p_entry jsonb,p_reason text)
+returns jsonb language sql volatile security invoker set search_path = public, pg_catalog, pg_temp as $$
+ select public.cme_amend_closed_entry(p_owner_id,p_entry_id,p_entry,p_reason)
+$$;
+
+revoke all on function
+ public.cme_entry_edit_version(uuid,uuid),
+ public.cme_save_entry(uuid,uuid,uuid,jsonb,boolean,uuid),
+ public.cme_amend_closed_entry(uuid,uuid,jsonb,text),
+ public.cme_save_entry_v2(uuid,uuid,uuid,jsonb,boolean,uuid),
+ public.cme_amend_closed_entry_v2(uuid,uuid,jsonb,text)
+from public, anon, authenticated;
+grant execute on function
+ public.cme_entry_edit_version(uuid,uuid),
+ public.cme_save_entry(uuid,uuid,uuid,jsonb,boolean,uuid),
+ public.cme_amend_closed_entry(uuid,uuid,jsonb,text),
+ public.cme_save_entry_v2(uuid,uuid,uuid,jsonb,boolean,uuid),
+ public.cme_amend_closed_entry_v2(uuid,uuid,jsonb,text)
+to service_role;
+alter function public.cme_entry_edit_version(uuid,uuid) owner to postgres;
+alter function public.cme_save_entry_v2(uuid,uuid,uuid,jsonb,boolean,uuid) owner to postgres;
+alter function public.cme_amend_closed_entry_v2(uuid,uuid,jsonb,text) owner to postgres;
+
+
+-- supabase/migrations/20261105163543_audit_retire_unfenced_document_reset.sql
+-- Retire the confirmed destructive, unfenced reset authority after first-party callers
+-- moved to generation-preserving enqueue/maintenance. Historical migration text and
+-- validators remain immutable. Old maintenance tooling must be quiesced before rollout.
+set local lock_timeout = '5s';
+set local statement_timeout = '30s';
+
+-- RESTRICT intentionally refuses an undiscovered catalog dependency; never cascade.
+-- The fenced worker reset_ingestion_attempt_artifacts(jsonb,uuid) is unchanged.
+drop function public.reset_document_index(uuid) restrict;
diff --git a/tests/admin-setup-sheet.dom.test.tsx b/tests/admin-setup-sheet.dom.test.tsx
index 740c078bcb..88e035d205 100644
--- a/tests/admin-setup-sheet.dom.test.tsx
+++ b/tests/admin-setup-sheet.dom.test.tsx
@@ -1,7 +1,8 @@
/** @vitest-environment jsdom */
-import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
+import { clearOnCallEntryCache } from "@/lib/on-call/entry-cache-keys";
import { AdminSetupSheet } from "@/components/admin/admin-setup-sheet";
const created = (title: string) => ({
@@ -36,6 +37,28 @@ function fillDates() {
}
describe("AdminSetupSheet", () => {
+ it("stops its bulk save after an account transition without delivering the old row", async () => {
+ let release!: (response: Response) => void;
+ const request = vi.spyOn(globalThis, "fetch").mockReturnValue(
+ new Promise((resolve) => {
+ release = resolve;
+ }),
+ );
+ const onCreated = vi.fn();
+ const onClose = vi.fn();
+ render();
+ fillDates();
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
+ expect(request).toHaveBeenCalledTimes(1);
+ clearOnCallEntryCache();
+ await act(async () => {
+ release(json(created("Registration"), 201));
+ });
+ expect(request).toHaveBeenCalledTimes(1);
+ expect(onCreated).not.toHaveBeenCalled();
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
it("echoes each date back in words, creates both rows, and stores nothing on the device", async () => {
const fetchMock = vi
.spyOn(globalThis, "fetch")
diff --git a/tests/admin-starter-ready-pages.dom.test.tsx b/tests/admin-starter-ready-pages.dom.test.tsx
index 1b5b492e77..531c4b3c11 100644
--- a/tests/admin-starter-ready-pages.dom.test.tsx
+++ b/tests/admin-starter-ready-pages.dom.test.tsx
@@ -1,9 +1,10 @@
/** @vitest-environment jsdom */
-import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ReadyForDayOnePage } from "@/components/admin/ready/ready-for-day-one-page";
+import { clearOnCallEntryCache } from "@/lib/on-call/entry-cache-keys";
import { StarterPackPage } from "@/components/admin/starter/starter-pack-page";
import { BOTH_REMINDERS_ON, CONTRACT_END_SLUG_PREFIX, reminderFields } from "@/lib/admin/contract-end";
import { STARTER_DATE_SLUG_PREFIX } from "@/lib/admin/starter-pack";
@@ -40,6 +41,8 @@ vi.mock("@/lib/on-call/entry-store", () => ({
// Like the real store, each read is its own snapshot, so a closure kept from an earlier render goes stale.
useOnCallEntries: () => ({ ...entryState }),
cacheOnCallEntries: (entries: OnCallEntry[]) => cacheOnCallEntries(entries),
+ updateCachedOnCallEntries: (_fallback: readonly OnCallEntry[], update: (current: OnCallEntry[]) => OnCallEntry[]) =>
+ cacheOnCallEntries(update([...entryState.entries])),
}));
const NOW = new Date("2026-10-06T01:00:00Z");
@@ -145,6 +148,18 @@ describe("StarterPackPage", () => {
await waitFor(() => expect(calls.some((call) => call.method === "DELETE")).toBe(true));
});
+ it("clears a retained saved-date Undo when the account changes", async () => {
+ const view = render();
+ fireEvent.click(screen.getByTestId("admin-starter-add-first"));
+ fireEvent.click(screen.getByTestId("admin-starter-kind-visa-end"));
+ fireEvent.change(screen.getByTestId("admin-starter-date-date"), { target: { value: "2027-01-14" } });
+ fireEvent.click(screen.getByTestId("admin-starter-date-save"));
+ await waitFor(() => expect(screen.getByTestId("admin-starter-undo")).toBeTruthy());
+ act(() => clearOnCallEntryCache());
+ view.rerender();
+ expect(screen.queryByTestId("admin-starter-undo")).toBeNull();
+ });
+
it("keeps a change made during the ten seconds when Undo removes the new date", async () => {
const view = render();
fireEvent.click(screen.getByTestId("admin-starter-add-first"));
diff --git a/tests/auth-confirm-route.test.ts b/tests/auth-confirm-route.test.ts
index f4c993b60a..cb4e1c7a39 100644
--- a/tests/auth-confirm-route.test.ts
+++ b/tests/auth-confirm-route.test.ts
@@ -186,6 +186,35 @@ describe("/auth/confirm", () => {
expect(response.headers.get("location")).toBe("https://psychiatry.tools/documents");
});
+ it.each(["https://evil.example", "null", ""])("rejects origin %j before consuming any body", async (origin) => {
+ const verifyOtp = vi.fn(async () => ({ error: null }));
+ const { POST } = await loadConfirm(verifyOtp);
+ const request = confirmPost("token_hash=h&type=email", origin ? { origin } : {});
+ const formData = vi.spyOn(request, "formData");
+ const getReader = vi.spyOn(request.body!, "getReader");
+ const response = await POST(request);
+ expect(response.status).toBe(303);
+ expect(formData).not.toHaveBeenCalled();
+ expect(getReader).not.toHaveBeenCalled();
+ expect(verifyOtp).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ["declared oversize", "token_hash=h&type=email", { "content-length": "20000" }],
+ ["streamed oversize", `token_hash=h&type=email&ignored=${"x".repeat(20000)}`, {}],
+ ["wrong content type", "token_hash=h&type=email", { "content-type": "text/plain" }],
+ ["overlong hash", `token_hash=${"h".repeat(1025)}&type=email`, {}],
+ ["overlong destination", `token_hash=h&type=email&next=${"n".repeat(4097)}`, {}],
+ ["duplicate token", "token_hash=h&token_hash=other&type=email", {}],
+ ] as const)("refuses %s before consulting Auth", async (_label, body, headers) => {
+ const verifyOtp = vi.fn(async () => ({ error: null }));
+ const { POST } = await loadConfirm(verifyOtp);
+ const response = await POST(confirmPost(body, { origin: "https://clinical.example", ...headers }));
+ expect(response.status).toBe(303);
+ expect(new URL(response.headers.get("location")!).searchParams.get("auth_error")).toBe("verify_failed");
+ expect(verifyOtp).not.toHaveBeenCalled();
+ });
+
it("refuses a cross-site post without spending the code", async () => {
const verifyOtp = vi.fn(async () => ({ error: null }));
const { POST } = await loadConfirm(verifyOtp);
@@ -193,7 +222,7 @@ describe("/auth/confirm", () => {
for (const headers of [{ origin: "https://evil.example" }, {}] as Array>) {
const response = await POST(confirmPost("token_hash=h&type=email&next=%2Fdocuments", headers));
expect(response.status).toBe(303);
- expect(response.headers.get("location")).toBe("https://clinical.example/documents?auth_error=verify_failed");
+ expect(response.headers.get("location")).toBe("https://clinical.example/?auth_error=verify_failed");
}
expect(verifyOtp).not.toHaveBeenCalled();
});
diff --git a/tests/auth-live-recheck.test.ts b/tests/auth-live-recheck.test.ts
index 6f92ea5b3e..6f2d91fc07 100644
--- a/tests/auth-live-recheck.test.ts
+++ b/tests/auth-live-recheck.test.ts
@@ -16,6 +16,7 @@ import {
requireAuthenticatedUser,
resolveOptionalAuthentication,
} from "@/lib/supabase/auth";
+import { publicAccessContext } from "@/lib/public-api-access";
import { signProxyAuthPayload } from "@/lib/supabase/proxy-auth-crypto";
const previousServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
@@ -85,6 +86,50 @@ describe("live account recheck on saves (PP-04)", () => {
);
});
+ it.each(["/api/answer", "/api/answer/stream", "/api/clinical-ask/stream", "/api/speech/transcribe"])(
+ "refuses blocked optional generation context at %s",
+ async (path) => {
+ const client = authClient({
+ data: {
+ user: { id: "synthetic-user", app_metadata: {}, banned_until: new Date(Date.now() + 60_000).toISOString() },
+ },
+ error: null,
+ });
+ const request = new Request(`http://localhost${path}`, {
+ method: "POST",
+ headers: requestWithProxyClaims("POST").headers,
+ });
+ await expect(publicAccessContext(request, client as never)).rejects.toBeInstanceOf(AuthenticationError);
+ expect(client.auth.getUser).toHaveBeenCalledTimes(1);
+ },
+ );
+
+ it.each(["POST", "PUT", "PATCH", "DELETE"])("rechecks optional proxy authentication on %s", async (method) => {
+ const client = authClient({
+ data: {
+ user: { id: "synthetic-user", app_metadata: {}, banned_until: new Date(Date.now() + 60_000).toISOString() },
+ },
+ error: null,
+ });
+ await expect(resolveOptionalAuthentication(requestWithProxyClaims(method), client as never)).resolves.toEqual({
+ status: "invalid",
+ });
+ expect(client.auth.getUser).toHaveBeenCalledWith("synthetic-token");
+ });
+
+ it("keeps valid optional saves and absent anonymous requests working", async () => {
+ const client = authClient({ data: { user: { id: "synthetic-user", app_metadata: {} } }, error: null });
+ await expect(resolveOptionalAuthentication(requestWithProxyClaims("POST"), client as never)).resolves.toMatchObject(
+ { status: "valid", user: { id: "synthetic-user" } },
+ );
+ expect(client.auth.getUser).toHaveBeenCalledTimes(1);
+ client.auth.getUser.mockClear();
+ await expect(
+ resolveOptionalAuthentication(new Request("http://localhost/api/answer", { method: "POST" }), client as never),
+ ).resolves.toEqual({ status: "absent" });
+ expect(client.auth.getUser).not.toHaveBeenCalled();
+ });
+
it("refuses a blocked account presenting a bearer token, and accepts one whose ban has passed", async () => {
const bearer = (bannedUntil: string) => ({
request: new Request("http://localhost/api/search", { headers: { authorization: "Bearer synthetic-token" } }),
diff --git a/tests/calendar-feed.test.ts b/tests/calendar-feed.test.ts
index ac113c6615..81ae0ed226 100644
--- a/tests/calendar-feed.test.ts
+++ b/tests/calendar-feed.test.ts
@@ -1,6 +1,7 @@
import { readFileSync } from "node:fs";
import { beforeEach, describe, expect, it, vi } from "vitest";
+import { DEMO_CME_YEAR } from "@/lib/cme/demo-year";
import type { OnCallEntry } from "@/lib/on-call/entry-model";
const mocks = vi.hoisted(() => ({
@@ -48,6 +49,7 @@ import {
hashCalendarFeedToken,
parseCalendarFeedToken,
} from "@/lib/calendar/feed-token";
+import { calendarFeedEvents } from "@/lib/calendar/feed-repository";
import { toIcs } from "@/lib/calendar/ics";
import { AuthenticationError } from "@/lib/supabase/auth";
@@ -89,6 +91,40 @@ beforeEach(() => {
mocks.bookings.mockResolvedValue({ status: "not-set-up" });
});
+describe("prior-year CPD reporting in the subscription", () => {
+ it.each(["2027-01-15T00:00:00Z", "2027-02-28T16:00:00Z", "2027-03-01T15:59:59Z"])(
+ "keeps a confirmed RANZCP deadline through its Perth calendar date at %s",
+ async (date) => {
+ mocks.cmeYear.mockImplementation(async (_client, _owner, year) =>
+ year === 2026
+ ? { ...DEMO_CME_YEAR, year, confirmedSource: "au-ranzcp-2026-v1; synthetic confirmed source" }
+ : null,
+ );
+ const events = await calendarFeedEvents({ from: mocks.from } as never, ownerId, new Date(date));
+ expect(mocks.cmeYear).toHaveBeenCalledWith(expect.anything(), ownerId, 2026);
+ expect(events).toContainEqual(expect.objectContaining({ id: "cme-2026-mycpd-close", date: "2027-03-01" }));
+ expect(events).not.toContainEqual(expect.objectContaining({ id: "cme-2026-year-end" }));
+ },
+ );
+
+ it("stops reading the prior year after reporting closes and retains current and next year events", async () => {
+ mocks.cmeYear.mockImplementation(async (_client, _owner, year) => ({ ...DEMO_CME_YEAR, year }));
+ const events = await calendarFeedEvents({ from: mocks.from } as never, ownerId, new Date("2027-03-01T16:00:00Z"));
+ expect(mocks.cmeYear).not.toHaveBeenCalledWith(expect.anything(), ownerId, 2026);
+ expect(events).toContainEqual(expect.objectContaining({ id: "cme-2027-year-end" }));
+ expect(events).toContainEqual(expect.objectContaining({ id: "cme-2028-year-end" }));
+ });
+
+ it.each([null, { ...DEMO_CME_YEAR, year: 2026, confirmedSource: "Another CPD home" }])(
+ "adds no reporting deadline for an unconfirmed or non-RANZCP prior year",
+ async (previous) => {
+ mocks.cmeYear.mockImplementation(async (_client, _owner, year) => (year === 2026 ? previous : null));
+ const events = await calendarFeedEvents({ from: mocks.from } as never, ownerId, new Date("2027-01-15T00:00:00Z"));
+ expect(events).not.toContainEqual(expect.objectContaining({ id: "cme-2026-mycpd-close" }));
+ },
+ );
+});
+
describe("calendar feed tokens", () => {
it("are 43 base64url characters and differ every time", () => {
const first = generateCalendarFeedToken();
diff --git a/tests/client-secret-surface.test.ts b/tests/client-secret-surface.test.ts
index 4b0b30d0c1..910ecbd0cb 100644
--- a/tests/client-secret-surface.test.ts
+++ b/tests/client-secret-surface.test.ts
@@ -179,4 +179,37 @@ describe("client environment isolation", () => {
rmSync(fixtureRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
+
+ it("scans the selected isolated build and fails closed on missing or invalid output", () => {
+ const scannerPath = join(ROOT, "scripts", "check-client-bundle-secrets.mjs");
+ const fixtureRoot = mkdtempSync(join(tmpdir(), "clinical-kb-isolated-bundle-"));
+ const distDir = ".next-playwright/delivery-test/dist";
+ const scan = (selected = distDir) =>
+ spawnSync(process.execPath, [scannerPath], {
+ cwd: fixtureRoot,
+ encoding: "utf8",
+ env: { ...process.env, NEXT_DIST_DIR: selected },
+ });
+ try {
+ // A clean default output must never hide a missing or unsafe selected output.
+ mkdirSync(join(fixtureRoot, ".next", "static"), { recursive: true });
+ expect(scan().status).toBe(1);
+ const staticRoot = join(fixtureRoot, distDir, "static");
+ mkdirSync(staticRoot, { recursive: true });
+ writeFileSync(join(staticRoot, "safe.js"), "console.log('safe');");
+ expect(scan().status).toBe(0);
+ writeFileSync(join(staticRoot, "unsafe.js"), "OPENAI_API_KEY");
+ const unsafe = scan();
+ expect(unsafe.status).toBe(1);
+ expect(unsafe.stderr).toContain(`${distDir}/static/unsafe.js`);
+ expect(scan("../elsewhere").status).toBe(1);
+ expect(scan(".next-playwright/test/../dist").status).toBe(1);
+ mkdirSync(join(fixtureRoot, "public"));
+ rmSync(join(staticRoot, "unsafe.js"));
+ writeFileSync(join(fixtureRoot, "public", "unsafe.txt"), "SUPABASE_SERVICE_ROLE_KEY");
+ expect(scan().status).toBe(1);
+ } finally {
+ rmSync(fixtureRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
+ }
+ });
});
diff --git a/tests/clinical-ask-orchestrator.test.ts b/tests/clinical-ask-orchestrator.test.ts
index 262f264643..a5d6b3a6be 100644
--- a/tests/clinical-ask-orchestrator.test.ts
+++ b/tests/clinical-ask-orchestrator.test.ts
@@ -330,6 +330,39 @@ describe("runClinicalAsk", () => {
expect(dependencies.synthesize).not.toHaveBeenCalled();
});
+ it.each(["before entry", "during progress"])("does not start dependencies when cancelled %s", async (when) => {
+ const controller = new AbortController();
+ const dependencies = fakes();
+ // A provider rejection from a detached, eagerly-started dependency would be
+ // unowned. A cancelled run must never create that operation in the first place.
+ dependencies.suggestContext.mockImplementation(() => Promise.reject(new Error("synthetic dependency failure")));
+ if (when === "before entry") controller.abort();
+ const response = await runClinicalAsk(request(), scope, dependencies, controller.signal, () => {
+ if (when === "during progress") controller.abort();
+ });
+ expect(response).toMatchObject({ state: "failed", code: "aborted" });
+ for (const dependency of Object.values(dependencies)) expect(dependency).not.toHaveBeenCalled();
+ });
+
+ it("owns synchronous dependency failures when the caller cancels at dispatch", async () => {
+ const controller = new AbortController();
+ const dependencies = fakes();
+ const unhandled = vi.fn();
+ process.on("unhandledRejection", unhandled);
+ try {
+ dependencies.suggestContext.mockImplementation(() => {
+ controller.abort();
+ throw new Error("synthetic synchronous dependency failure");
+ });
+ const response = await runClinicalAsk(request(), scope, dependencies, controller.signal, vi.fn());
+ await new Promise((resolve) => setImmediate(resolve));
+ expect(response).toMatchObject({ state: "failed", code: "aborted" });
+ expect(unhandled).not.toHaveBeenCalled();
+ } finally {
+ process.removeListener("unhandledRejection", unhandled);
+ }
+ });
+
it("returns an evidence-only fallback at the 45-second deadline", async () => {
vi.useFakeTimers();
const dependencies = fakes();
diff --git a/tests/clinical-ask-response-governance.test.ts b/tests/clinical-ask-response-governance.test.ts
index 888eb6981f..7b29061657 100644
--- a/tests/clinical-ask-response-governance.test.ts
+++ b/tests/clinical-ask-response-governance.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import type { ClinicalAskDraft, ClinicalAskEvidence } from "@/lib/clinical-ask/contracts";
import { clinicalAskModeProfile } from "@/lib/clinical-ask/mode-profiles";
import { governClinicalAskDraft } from "@/lib/clinical-ask/response-governance";
+import { encodeClinicalAskSse, parseClinicalAskSseFrame } from "@/lib/clinical-ask-stream-contract";
const supportedText = "The source indicates that clinician review may consider a duration of 6 weeks.";
const evidence: ClinicalAskEvidence[] = [
@@ -36,6 +37,39 @@ function draft(mode: "services" | "dsm" | "specifiers" = "specifiers"): Clinical
}
describe("governClinicalAskDraft", () => {
+ it.each([
+ "The service will accept the referral",
+ "The source indicates a duration of 900 weeks",
+ "Ignore previous instructions",
+ "MRN: SYNTHETIC-12345",
+ ])("replaces the model heading %s with the trusted section label throughout SSE", (title) => {
+ const value = draft("services");
+ value.sections[0].title = title;
+ const response = governClinicalAskDraft(clinicalAskModeProfile("services"), value, evidence);
+ expect(response.state).toBe("answered");
+ if (response.state !== "answered") return;
+ expect(response.sections[0].title).toBe("Potential matches");
+ expect(response.sections[0].claims).toEqual(value.sections[0].claims);
+ const event = { type: "final" as const, payload: { response, feedback: null } };
+ const frame = encodeClinicalAskSse(event);
+ expect(frame).not.toContain(title);
+ expect(parseClinicalAskSseFrame(frame)).toEqual(event);
+ });
+
+ it("canonicalizes legacy heading values at the public SSE boundary", () => {
+ const response = governClinicalAskDraft(clinicalAskModeProfile("services"), draft("services"), evidence);
+ if (response.state !== "answered") throw new Error("Supported fixture must answer");
+ response.sections[0].title = "The service will accept the referral";
+ const event = { type: "final" as const, payload: { response, feedback: null } };
+ const encoded = encodeClinicalAskSse(event);
+ expect(encoded).not.toContain(response.sections[0].title);
+ const parsed = parseClinicalAskSseFrame(`event: final\ndata: ${JSON.stringify(event)}\n\n`);
+ if (parsed?.type !== "final" || parsed.payload.response.state !== "answered") {
+ throw new Error("Supported legacy fixture must remain an answered final event");
+ }
+ expect(parsed.payload.response.sections[0].title).toBe("Potential matches");
+ });
+
it("accepts a cited, directly supported Specifiers answer", () => {
expect(governClinicalAskDraft(clinicalAskModeProfile("specifiers"), draft(), evidence).state).toBe("answered");
});
diff --git a/tests/clinical-ask-runner.dom.test.tsx b/tests/clinical-ask-runner.dom.test.tsx
index 485cec27fc..b8968ca6bf 100644
--- a/tests/clinical-ask-runner.dom.test.tsx
+++ b/tests/clinical-ask-runner.dom.test.tsx
@@ -33,6 +33,12 @@ function RunnerHarness({ query, online = true }: { query: string; online?: boole
+
+