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 88d79880ff..4e78ee450e 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -4069,6 +4069,11 @@ "section": "decisions", "catalogued": true }, + { + "path": "docs/backend-audit-remediation-2026-11-05.md", + "section": "root", + "catalogued": false + }, { "path": "docs/production-readiness.md", "section": "root", diff --git a/docs/agents/document-drafts.md b/docs/agents/document-drafts.md index 800a702e67..59240e818d 100644 --- a/docs/agents/document-drafts.md +++ b/docs/agents/document-drafts.md @@ -20,8 +20,10 @@ Older workspaces can gain input snapshots by reconciling once while their inputs Export emits absolute `path`, current `before_sha256` and `after_base64` fields for the existing integrator. It is a proposal. The integrator must check claims and required gates, establish a serial write, compare the current original hash immediately before applying, and re-reconcile changed inputs. The helper has no apply command and grants no release, takeover, publication or additional authority. Keep governing-rule and substantive conflicts for their authorised decision-maker. +On POSIX, new task workspaces are owned by the current user with mode 0700 and new copied artifacts use mode 0600. Existing broader-permission or another-owner workspaces are refused without changing them: preserve the old draft/candidate and reconcile it in a fresh private task-owned workspace. Windows filesystem ACL privacy still needs verification on its actual host. Per-file reads stop at the 10 MiB limit plus one byte before rejecting oversized input; this is not an OS adversarial-filesystem sandbox. + Supported inputs: UTF-8 `.md`, `.txt`, `.rst`, `.adoc`; at most 100 exact paths and 10 MiB total. Traversal, hidden/sensitive/configuration names, links/reparse points, changed baselines, stale exports and busy locks fail closed. Filename screening does not certify contents safe. Never copy secrets, private sessions or data outside the task purpose/audience. Never reclaim an uncertain lock by age. Run the synthetic safety/merge checks with `python3 scripts/test-document-drafts.py`. The dedicated Document drafts workflow runs the same checks on a hosted Linux runner after relevant PR changes. That proves helper behaviour on that runner; it does not prove any chat loaded or followed the rules. -This copy reuses the reviewed shared helper, SHA-256 `f9f919523098e8ac0a00d8082e593c0bfca969bb05163f5d1f957f31262c39fe`. Maintain helper and tests together; compare reviewed hashes before updating another copy. No private instruction source or Windows installation is required on cloud hosts. A cloud checkout must select this feature branch or a main revision containing it; a PR does not change existing checkouts. Coordinate only task-safe metadata through supported native task tools with destination authority. If a host is offline, preserve the draft and continue independent authorised work; do not collect raw sessions or create a replacement chat service. +This copy includes locally reviewed private-workspace and bounded-read safeguards, SHA-256 `990e3017b9e0ccccd5800660cb87088f47c9c19ff003740d65592d0765c3d939`. The shared-helper predecessor was `f9f919523098e8ac0a00d8082e593c0bfca969bb05163f5d1f957f31262c39fe`. Maintain helper and tests together; compare reviewed hashes before updating another copy. No private instruction source or Windows installation is required on cloud hosts. A cloud checkout must select this feature branch or a main revision containing it; a PR does not change existing checkouts. Coordinate only task-safe metadata through supported native task tools with destination authority. If a host is offline, preserve the draft and continue independent authorised work; do not collect raw sessions or create a replacement chat service. diff --git a/docs/audit/tenancy-defense-in-depth-review.md b/docs/audit/tenancy-defense-in-depth-review.md index d8d05be1ab..61003b3346 100644 --- a/docs/audit/tenancy-defense-in-depth-review.md +++ b/docs/audit/tenancy-defense-in-depth-review.md @@ -516,7 +516,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/backend-audit-remediation-2026-11-05.md b/docs/backend-audit-remediation-2026-11-05.md new file mode 100644 index 0000000000..a64ca7183e --- /dev/null +++ b/docs/backend-audit-remediation-2026-11-05.md @@ -0,0 +1,291 @@ +# Backend audit: local remediation and remaining acceptance + +Recorded 5 November 2026. Implementation source: `33f6478c63aedf9c2d79effd9890951035f552b2`; original audit: `c92e78113abd936c53f88e7e069b2c7331c735a2`. This packet records local corrections and remaining work. Merge, deployment, live SQL, paid provider execution and specialist approvals are separate. + +The audit classified 67 dispositions: 19 original findings, 3 later repository observations and 45 followups. They include defects, supported risks, improvements, intentional constraints and acceptance gaps. They are not 67 new software bugs. All disposition IDs appear in the crosswalk below. + +The code changes prevent delayed private callbacks crossing account boundaries, preserve generation evidence during document maintenance, purge all 17 private SQL representations on future permanent deletions, enforce coherent CPD/Teaching/hospital transitions, guard stale OnCall attestation, bound Clinical Ask requests and correct monitoring/recovery/build tools. New source admission remains unavailable and explicitly refuses before persistence. Eight new forward migrations are drafted: the original seven plus `20261107000800_audit_fix_maintenance_snapshot_fence_aliases.sql`, which corrects the maintenance snapshot fences in the fourth; all 349 historical migrations remain unchanged. No ranking thresholds, clinical fixtures or activation holds were weakened. + +## Verification and limits + +- Four completed native unit partitions: 2,152 actual files; 34,453 passed, zero failures, two existing conditional skips. This is earlier evidence, not a full rerun on the final dependency graph. +- Subsequent commit-phase and build corrections passed 94 and 47 affected cases. Final dependency changes passed 203 tests across 15 affected files, whole lint/typecheck, normal production and isolated browser builds, and both output marker scans. Latest overlapping per-file evidence yields 34,459 passes and two skips; it is a controller reconciliation, not a fabricated native full-run report. +- Real PostgreSQL roles, policies, transactions and lock contention: fresh-seven/upgrade parity has151 public tables,382 functions and59 private objects. That parity evidence predates the eighth migration and has not been rerun with it. Original bare-image history fails at existing SF01; a separately stronger baseline executes the remainder conditionally. Faithful pinned CLI/journal and hosted acceptance remain open. +- Actual small synthetic6.28MB database/Vault restore and18 local object bytes pass fingerprints/SHA checks. This does not establish hosted Auth/StorageHTTP or production RPO/RTO. +- Native dependency-consumer probes:32 matching before/after cases plus6 glob/rimraf controls; patched follow-redirects has confined loopback negative/positive proof. The three returned installed NPM candidates are patched. This is not a vulnerability-free or bundled-CodSpeed certification. +- Six current Chromium151 guest scenarios pass at desktop/phone widths, including Escape/focus restoration; no page exceptions/overflow/unexpected external or mutation requests. Required Chromium156 download remains blocked. Real Auth, installed PWA, physical Safari and accessibility conformance are separate. +- Detailed sanitised local evidence remains in the owner review package; no credentials, private logs, model weights or production backup are published in this packet. + +## Task authority and deduplication + +[Canonical PsychSift task ledger](https://app.notion.com/p/3ec7889e8a2281e4bab9ca667a2a76df) owns lifecycle/blocker/next action. Repository issue history uses [the immutable inbox](outstanding-issues-inbox/README.md); this branch does not edit [the protected canonical ledger](outstanding-issues.md). Seventeen validated requests comprise10new proposals,6existing updates and1update targeting the existing SF01 pending-add identity. Two existing packages are reused. Existing owner decisions, pending requests and unrelated canonical tasks are preserved. Reconciliation occurs only after this PR lands, on its dedicated fresh-main transaction. + +## Remaining work packages + +### ROLLOUT — Review and safely roll out the audited app/worker fixes and eight draft migrations + +Repository: #36XZRQ. [Immutable request](outstanding-issues-inbox/dd15ac8c-3ea7-4b63-ad95-deae8c36faa8.json). State: Needs you. Suggested owner: Delivery + Database + owner. + +Blocker: Local corrections are committed and checked; target deployment, supported CLI/journal replay and old/new fleet acceptance are unverified. Opening a PR does not authorize merge, live SQL or deployment. + +Next action: Review candidate PR and all eight immutable forward drafts (including `20261107000800_audit_fix_maintenance_snapshot_fence_aliases.sql`), rerun parity/replay with the eighth, verify exact target/project/default-ACL prerequisites, rehearse supported replay and mixed versions, quiesce old maintenance/unconditional verification handlers; obtain separate rollout authority. + +Acceptance: Correct owner/role validation, actual hosted RPC contracts, private drift coverage, malformed historical booking timestamp preflight, stale/concurrent mutation refusal, calendar rollover and versioned-client failure behavior; no old unsafe writer persists. No protected clinical content/flags/history edits. + +### ADMISSION — Define authenticated provenance admission before restoring new backend document intake + +Repository: #QQ29E9. [Immutable request](outstanding-issues-inbox/7f71bf93-fd2d-4969-ab2c-77f32fd16a94.json). State: Needs you. Suggested owner: Source intake + Security + owner. + +Blocker: admit_backend_document is absent; existing admitted resume/force is repaired, new intake now refuses before bytes/writes. Roles/source classes/provenance/review contract is not settled. + +Next action: Owner defines admission authority per source class and mandatory review/provenance; implement atomic idempotent backend admission and entry-point negative/retry tests before enabling new intake. + +Acceptance: No persisted bytes/job orphan on refusal or partial failure; authenticated owner/resource authority and provenance enforced at durable writer; retries reconcile one intent. Preserve upload-local/rights/scanner/reindex holds. + +### READ_POLICY — Set private-read and independent bearer/feed revocation semantics + +Repository: #6WD257. [Immutable request](outstanding-issues-inbox/3871a4f5-fd70-4ebe-a62f-c516271fda12.json). State: Needs you. Suggested owner: Identity + product owner. + +Blocker: Immediate private GET revocation policy remains unsettled; established verified-token read fast path and separately issued bearer/feed scope/expiry remain intentional constraints. + +Next action: Specify which private reads need live account bans/revocation, freshness bounds and behavior for independently issued feed/signed credentials; then implement and verify only the selected policy. + +Acceptance: Real Auth two-user expired/revoked/blocked/session-refresh tests match documented policy across HTTP/cache/export/feed paths. Do not equate sign-out with automatic revocation of every independent credential. + +### AUDIO — Choose and enforce a trusted server decoded-audio duration limit + +Repository: #6909Z0. [Immutable request](outstanding-issues-inbox/044a35cd-2e56-455b-b742-cfab7318f11b.json). State: Needs you. Suggested owner: API + Delivery + product owner. + +Blocker: 120second synthetic WAV reaches mocked provider with absent/forged duration metadata. Byte/rate caps exist; no trusted duration cap across accepted compressed formats or declared decoder/runtime contract. + +Next action: Choose supported duration/formats and bounded trusted decoder; verify package/image supply chain and time/memory limits before enforcing decoded duration. + +Acceptance: Actual synthetic accepted-format boundary, forged metadata, malformed/oversized/decompression/cancellation and decoder failure tests refuse safely before provider work. No WAV-only or client-metadata substitute. + +### DEPENDENCY_IMAGE — Worker image dependency acceptance: retain advisory policy and validate the Pillow/Docling security upgrade + +Repository: #CK9GBW. [Immutable request](outstanding-issues-inbox/b7a42470-1b20-402c-a85e-d255fd6e6d87.json). State: Blocked. Suggested owner: Extraction + Delivery/security owner. + +Blocker: Current Docling2.139 caps Pillow<13; candidate2.142 permits13 but model/dependency/image compatibility is not verified. Python3.11 works; one new host-root probe meets proxyCONNECT403, earlier per-model errors remain individually unclassified. + +Next action: Append current source facts to #CK9GBW while preserving its24finding history and2December policy. Establish permitted artifact route and actual resource bounds, regenerate hash-correct candidate lock/model tree, run real raster-PDF/OCR and image assessment. + +Acceptance: No guessed hashes/model identities, suppression expiry renewal, scan threshold change or clinical activation. Exact candidate runtime/models/normal image controls pass and fresh accepted/advisory applicability is recorded. + +### DATA_LIFECYCLE — Complete historic evidence retention and deletion across derived stores and replay paths + +Repository: #YK1BX8. [Immutable request](outstanding-issues-inbox/8901d677-1944-492b-b471-21b2e68a98cc.json). State: Needs you. Suggested owner: Data + privacy owner + operations. + +Blocker: Prospective permanent deletion now purges17private SQL representations, but historic orphan/abandoned evidence, StorageHTTP removal, worker/queue replay, backups and legal-hold treatment are unverified. No live occupancy/sweep was performed. + +Next action: Agree retention/legal holds and run read-only synthetic-target inventory; design owner-approved cleanup/reconciliation and deletion reapplication after restore. + +Acceptance: Actual isolated Auth/Storage/worker deletion removes only intended owner data and derivatives; delayed jobs/reindex/restore cannot resurrect or disclose deleted records; legitimate retention is explicit. No live sweep is authorized by task logging. + +### WORKER_RECOVERY — Verify worker parser isolation and crash/retry/ambiguous-ack recovery on an isolated stack + +Repository: #QD32S0. [Immutable request](outstanding-issues-inbox/539e7e69-ea40-4964-957c-d5930fffb77f.json). State: Blocked. Suggested owner: Workers + Database + integrations + Delivery. + +Blocker: Actual SQL claims/fencing/rollback pass, but full process crash around external effects and acknowledgement, retries, revocation/deletion replay, parser OS/network isolation and cancellation are not established. + +Next action: Prepare owned isolated real-stack fixtures with fault injection and bounded provider test contracts; inspect target/side effects/cost before any provider-backed run. + +Acceptance: Crash at durable write/external acceptance/ack boundaries; duplicates/out-of-order/revoked owner/stale lease/delete-replay produce intended effects only, bounded retries/cost and diagnosable state. Env stripping/offline flags are not an OS sandbox. + +### STAGING_AUTH — Obtain exact-candidate two-user Auth/HTTP ownership and account-switch proof + +Repository: #F9HZEG. [Immutable request](outstanding-issues-inbox/dce12b53-ef7e-44b1-94ff-98a6ed9fe36a.json). State: Blocked. Suggested owner: Identity + app/Teaching owners + staging operator. + +Blocker: Component/SQL roles are strong local evidence; six guest flows do not exercise real Auth/JWT/HTTP ownership or two signed-in identities. Existing staging rebuild/SHA alignment gap remains. + +Next action: Append audit acceptance to #F9HZEG, retaining existing staging blocker; use exact deployed candidate and two synthetic users/roles to exercise delayed account switch, stale/refused mutations and privileged/anonymous/cross-owner paths. + +Acceptance: Identity/deployedSHA verified; same-owner success, other-owner/nonexistent/anonymous/denied role refusal, stale callbacks/cache/exports after switch/revocation tested at enforcement owner. No success credited from tolerated scheduled failure. + +### RECOVERY — Complete hosted-service recovery acceptance; small synthetic database/local-byte restore now passes + +Repository: #QVVS6W. [Immutable request](outstanding-issues-inbox/a3ff5894-2b9a-4589-ac74-224bbf68469b.json). State: Blocked. Suggested owner: Operations + Database + owner. + +Blocker: New6.28MB synthetic restore matches17private fingerprints/Vault/CPD and18local object bytes, but hosted Auth/StorageHTTP, production backup, lost-key and production RPO/RTO remain unverified. + +Next action: Update #QVVS6W title/detail with exact narrow new proof, preserving original plan; schedule separately approved isolated full-service restore, roll forward final migrations and reapply deletion policy. + +Acceptance: Verified target; restored sign-in/session/storage bytes/download and core workflows usable with owner/other-owner controls; measured time/data loss and cleanup recorded. Preserve accepted ~24hour RPO decision rather than re-opening paid PITR. + +### SOAK_CAPACITY — Complete SHA-bound staging soak, mixed-version rollback and representative capacity/cost evidence + +Repository: #057. [Immutable request](outstanding-issues-inbox/869ecf69-784e-4124-94a7-8243083eda15.json). State: Blocked. Suggested owner: Delivery + performance owner + operator. + +Blocker: The local small fixtures/build timings are not representative capacity/cost proof. Existing #057 staging deployment alignment/soak/rollback remains pending. + +Next action: Append tested-boundary gaps to #057 and reuse #H69FNZ for hydration attribution; on approved isolated candidate disclose synthetic dataset, concurrency, caches, resource/cost caps and sampling before bounded measurements. + +Acceptance: Meaningful latency/error distributions/sample counts; no silent lost writes, starvation or unbounded fanout/retries/cancellation work; old/new fleet rollback compatible and observed. Distinguish public metadata/free setup from billable provider work. + +### OPS_MONITOR — Verify deployed failure and recurring-error visibility under the daily health-summary policy + +Repository: #5EQXXF. [Immutable request](outstanding-issues-inbox/ebe37fb2-e909-4748-8af9-088ac635e90f.json). State: Blocked. Suggested owner: Delivery + operations owner. + +Blocker: False-green monitor summary is repaired locally; current deployed notification delivery, recurring Sentry-group coverage and release-correlated diagnosis remain unverified. New-group webhook semantics alone do not prove recurrence detection. + +Next action: Verify exact current app/worker/release selectors and alert configuration, then use approved harmless synthetic failures to prove failed/delayed/partial/recurrent operations reach the existing daily digest and operator. + +Acceptance: A known failed/unavailable run never renders healthy; recurrence is visible under the agreed7amPerth digest policy; diagnosis links request/job/release without sensitive payloads. Do not enable parked sourcemap uploads or send real notifications without separate authority. + +### DEPLOY_POLICY — Verify actual deployment gate and preserve the approved Wait for CI policy + +Repository: #HYSWAF. State: Needs you. Suggested owner: Owner + Delivery. + +Blocker: Existing owner decision is Wait for CI OFF until a narrower safe gate; current provider settings were not read. + +Next action: Reuse #HYSWAF and verify current app/worker selectors/settings with applicable authority; do not ask the owner to decide the same OFF policy again. + +Acceptance: Record actual selectors/deployment/check identities; any narrower gate change is separately approved. No blanket enable-WaitForCI task. + +### SCHEMA_PLATFORM — Supabase support: assess reserved-creator schema and global default-privilege prerequisites + +Repository: #MXHK8F. [Immutable request](outstanding-issues-inbox/d32690bf-8a80-4582-9d5c-b06e17badb64.json). State: Blocked. Suggested owner: Supabase platform owner + Database. + +Blocker: Existing support task is already pending and owner-deferred. Isolated bare-image replay additionally shows globalPUBLICfunction default cannot be subtracted by per-schema revoke; hosted global state was not read. + +Next action: Append the new isolated global-default diagnosis to the existing pending add via targetUlid; preserve its original owner support deferral and schema-specific facts. Have platform owner inspect namespace0/global and schema ACLs before proposing broader changes. + +Acceptance: Original143payload success/144thSF01 failure retained; stronger synthetic continuation is conditional, not faithful CLI/hosted replay. Any global correction is platform-approved with cross-schema compatibility review; no reserved-role escalation/history repair/allowlist widening. + +### PINNED_BROWSER — Complete pinned browser/UI acceptance; current Chromium151 demo evidence is exploratory + +Repository: #WR6FAW. [Immutable request](outstanding-issues-inbox/e1ca65c7-e74e-4abb-8c42-f9e1a5c5932b.json). State: Blocked. Suggested owner: Delivery + test environment owner. + +Blocker: Playwright1.64.1 requiresChromium156revision1248; official download403. Six currentChromium151guest flows pass, but historical three-failure floor belongs to an older host/browser and is not independently established here. + +Next action: Append current source/runtime evidence to #WR6FAW and link existing #PM71Z1/pending host update rather than another competing mutation; obtain official pinned runtime on a permitted host and run unchanged selected UI gates. + +Acceptance: Exact candidate/build/session/role/browser identity; required pinned proof passes without changing assertions, pins or host security. Do not infer physical Safari/PWA/Auth/conformance from151emulation. + +### PHYSICAL_DEVICE — Complete physical iPhone Safari and installed-PWA microphone/task acceptance + +Repository: #93E7YJ. [Immutable request](outstanding-issues-inbox/b3044672-a6dd-4bfa-81ad-a057b7c0b810.json). State: Blocked. Suggested owner: Product/device tester + clinical owner. + +Blocker: No physical device/assistive-technology/installed-PWA acceptance occurred. Chromium viewport flows do not establish Safari microphone, software keyboard, offline lifecycle or assistive conformance. + +Next action: Use synthetic inputs on an approved exact build; test actual iPhone Safari and installedPWA with permission/error/interruption/keyboard/overlay/focus/account-switch states. + +Acceptance: Record hardware/OS/build/config and user roles; microphone consent/interruption/cancellation and task recovery work with no sensitive data or downstream cost outside approved scope. Keep conformance claims proportional. + +### PRIVACY — Close already-tracked privacy provider, legal and clinical approvals + +Repository: #BS3SN9, #XJ3N16. State: Needs you. Suggested owner: Owner + privacy/legal/clinical specialists. + +Blocker: ZDR/project coverage, APP8, APP1/5 notices, PHI minimisation and contractual retention/region remain owner/specialist evidence. Source/tests cannot approve these. + +Next action: Reuse #BS3SN9 and #XJ3N16 plus pending adviser/DPA records; attach audit crosswalk without duplicating or re-signing approvals or replacing existing drafts. + +Acceptance: Dated project-specific provider/contracts and qualified adviser/clinical approvals recorded; existing no-identifiable-patient-data/launch holds preserved. No inference from environment strings or test pass. + +### CLINICAL_RELEASE — Run exact-candidate release acceptance with remaining Clinical Ask seven-mode readiness evidence + +Repository: #055. [Immutable request](outstanding-issues-inbox/a9bb0fe1-8342-4f9b-8189-b16ec08d9eb5.json). State: Needs you. Suggested owner: Owner + clinical authority + release operator. + +Blocker: Existing #055 release gate is open. Deployed mode/flag/transcription/feedback-schema state, named clinical authority, seven-mode evaluation and protected staging canary are not established by deterministic offline checks. + +Next action: Append L01-L05 acceptance to #055, preserve existing cross-browser/CI/review requirements and #TXVBAZ accepted diagnosis/treatment constraints. Obtain only the missing specific approvals/bounded evaluation authority. + +Acceptance: Exact SHA/mode/config/provider-target evidence, real feedback migration presence, dated clinical authority, seven-mode usefulness/safety evaluation and approved canary meet unchanged gates. No flags/content/ranking/protected fixture changes or forged approvals. + +### SECURITY_WORKFLOW — Record remaining specialised security-workflow acceptance when supported capability is available + +Repository: #6PT217. [Immutable request](outstanding-issues-inbox/ef7c5dcf-1145-4611-81a6-f10784c2f6e6.json). State: Blocked. Suggested owner: Security owner. + +Blocker: Required referenced Codex Security core/preflight capability was unavailable; no branded scan was registered or completed. Native threat/source/runtime review is separate valid evidence. + +Next action: Assess whether a supported specialised workflow adds material assurance; if selected, verify prerequisites/target and bounded runtime/spend before one canonical scan. Otherwise retain explicit native-review scope and unavailable-branded evidence. + +Acceptance: No fabricated canonical artifacts/equivalence or duplicate uncertain scans; any chargeable run has separately approved total/cap. This is an assurance recommendation, not a confirmed software vulnerability. + +### WINDOWS_MAINTENANCE — Complete remaining Windows document-copy and dependency-consumer acceptance + +Repository: #S1N8RH. [Immutable request](outstanding-issues-inbox/984893be-f977-4951-b1b3-029155589c8b.json). State: Blocked. Suggested owner: Developer tooling + Windows test owner. + +Blocker: POSIX0700/0600copies and Linux32consumer cases pass. Windows ACL/path behavior, aggregate preparation peak memory and retained-history disk are outside that proof; scoped overrides and CodSpeed bundle qualification need maintenance. + +Next action: On actual supported Windows/Node24, verify copied-document ACL audience and the bounded native consumer matrix. Measure aggregate resource/history behavior before choosing optional additional limits; review/remove overrides as upstream adopts fixed compatibility. + +Acceptance: No private ACL broadening or unsafe existing-workspace repair; patterns/ignore/skip and owned deletion keep correct Windows paths; source preserved. Avoid claiming a proven Windows bug or arbitrary-pattern safety. CodSpeed bundle remains a qualification unless actual vulnerable reach is established. + +## Complete audit-disposition crosswalk + +A local correction awaiting rollout maps to its rollout/integration package. Intentional or fully verified local constraints do not become new defects. + +- **ACCESS-01** — Late On Call mutation callbacks restore prior-account private entries after an auth transition. local correction committed; see scoped verification and remaining hosted acceptance. Remaining: ROLLOUT, STAGING_AUTH. +- **ING-RAG-01** — Alternate intake and maintenance writers delete current index evidence before replacement is durable. local correction committed; see scoped verification and remaining hosted acceptance. Remaining: ROLLOUT, WORKER_RECOVERY. +- **ACCESS-02** — Generation POSTs using optional authentication omit live blocked-account checks. local correction committed; see scoped verification and remaining hosted acceptance. Remaining: ROLLOUT, STAGING_AUTH. +- **ACCESS-03** — Confirmation POST parses unrestricted form body before same-origin rejection. local correction committed; see scoped verification and remaining hosted acceptance. Remaining: ROLLOUT, STAGING_AUTH. +- **DATA-01** — Permanent document deletion retains complete original bytes in private ingestion evidence. local correction committed; see scoped verification and remaining hosted acceptance. Remaining: ROLLOUT, DATA_LIFECYCLE. +- **DATA-02** — Recurring schema drift inventory excludes private ingestion integrity controls. local correction committed; see scoped verification and remaining hosted acceptance. Remaining: ROLLOUT, SCHEMA_PLATFORM. +- **DEL-01** — Fresh failed monitoring runs can be rendered healthy in the daily health summary. local correction committed; see scoped verification and remaining hosted acceptance. Remaining: ROLLOUT, OPS_MONITOR. +- **DEL-02** — Storage recovery acceptance checks metadata rather than recovered object bytes. byte verifier corrected and synthetic database/local-byte recovery passed. Remaining: RECOVERY. +- **DP-01** — Closing CPD year can freeze requirement statuses from a different record. local correction committed; see scoped verification and remaining hosted acceptance. Remaining: ROLLOUT, STAGING_AUTH. +- **DP-02** — Calendar feed loses previous year's March reporting deadline at January rollover. local correction committed; see scoped verification and remaining hosted acceptance. Remaining: ROLLOUT, STAGING_AUTH. +- **DP-03** — Late On Call list response overwrites a newer successful mutation. local correction committed; see scoped verification and remaining hosted acceptance. Remaining: ROLLOUT, STAGING_AUTH. +- **DP-04** — Closed-year CPD log offers copied-state updates that database always rejects. local correction committed; see scoped verification and remaining hosted acceptance. Remaining: ROLLOUT, STAGING_AUTH. +- **EXT-ROLES-JOBS-01** — A stale hospital selection commits a team link to an archived hospital before returning 403. local correction committed; see scoped verification and remaining hosted acceptance. Remaining: ROLLOUT, STAGING_AUTH. +- **EXT-ROLES-JOBS-02** — Legacy enrichment metadata writes can restore an obsolete committed generation pointer. local correction committed; see scoped verification and remaining hosted acceptance. Remaining: ROLLOUT, WORKER_RECOVERY. +- **EXT-STREAM-01** — Clinical Ask dispatches a question cancelled before lazy client initialization. local correction committed; see scoped verification and remaining hosted acceptance. Remaining: ROLLOUT, WORKER_RECOVERY. +- **EXT-STREAM-02** — Clinical Ask external search bypasses the common output ceiling and has no explicit tool-call budget. local correction committed; see scoped verification and remaining hosted acceptance. Remaining: ROLLOUT, WORKER_RECOVERY. +- **ING-RAG-02** — Bulk import cannot recover a document persisted before its job insert fails. partly resolved; unsupported new admission explicitly refused. Remaining: ADMISSION. +- **ING-RAG-03** — Clinical Ask section headings bypass deterministic draft governance. local correction committed; see scoped verification and remaining hosted acceptance. Remaining: ROLLOUT, CLINICAL_RELEASE. +- **EXT-STREAM-03** — Roster calendar fetch ten-second total deadline does not cover DNS observation. local correction committed; see scoped verification and remaining hosted acceptance. Remaining: ROLLOUT, WORKER_RECOVERY. +- **RD-01** — Document draft copies broaden private POSIX file permissions under ordinary umask. local correction committed; see scoped verification and remaining hosted acceptance. Remaining: WINDOWS_MAINTENANCE. +- **RD-02** — Document size rejection follows an unbounded per-file read. local correction committed; see scoped verification and remaining hosted acceptance. Remaining: WINDOWS_MAINTENANCE. +- **DEL-03** — Isolated browser build loses its declared heap budget at the TypeScript child boundary. local correction committed; see scoped verification and remaining hosted acceptance. Remaining: PINNED_BROWSER. +- **F01** — Private GET account freshness. unsettled policy. Remaining: READ_POLICY. +- **F02** — Independent bearer/feed revocation semantics. unsettled policy. Remaining: READ_POLICY. +- **F03** — EPA history older than fifty. implemented locally. Remaining: ROLLOUT, STAGING_AUTH. +- **F04** — EPA pending-link concurrency cap. verified local engine. Remaining: ROLLOUT, STAGING_AUTH. +- **F05** — Registry metadata stale replacement. implemented locally. Remaining: ROLLOUT, WORKER_RECOVERY. +- **F06** — Malformed/mixed-version OnCall responses. verified component contracts. Remaining: ROLLOUT, STAGING_AUTH. +- **F07** — Ordinary CPD stale full edits. verified local engine. Remaining: ROLLOUT, STAGING_AUTH. +- **F08** — Trusted decoded speech-duration cap. supported cost-control gap; policy pending. Remaining: AUDIO. +- **F09** — Pre-abort orchestration rejection ownership. implemented locally. Remaining: ROLLOUT, WORKER_RECOVERY. +- **F10** — CME eager101-year fanout. implemented locally. Remaining: SOAK_CAPACITY. +- **F11** — Supervisor double full-history payload. verified local engine. Remaining: ROLLOUT, SOAK_CAPACITY. +- **R01** — Seven accepted Pillow advisories. supported unresolved dependency risk. Remaining: DEPENDENCY_IMAGE. +- **R02** — Shadow parser environment and isolation. partly resolved. Remaining: WORKER_RECOVERY. +- **R03** — Suppression expiry and scan blocking policy. intentional policy plus review need. Remaining: DEPENDENCY_IMAGE. +- **R04** — WaitForCI and independent release approval policy. intentional policy; live state unverified. Remaining: DEPLOY_POLICY. +- **R05** — Report-only migration boot default. intentional operating mode. Remaining: ROLLOUT. +- **R06** — Sentry recurring-error detection. supported operational gap. Remaining: OPS_MONITOR. +- **R07** — Reserved-creator global future-function default ACL (existing SF01). confirmed isolated replay blocker; hosted state unverified. Remaining: SCHEMA_PLATFORM, ROLLOUT. +- **R08** — Public NPM advisory candidates and bundled dev code. all3inspectedinstalledcandidates corrected; currentgraphgatespass. Remaining: WINDOWS_MAINTENANCE. +- **V01** — Full schema and migration replay. final-seven parity (predates the eighth migration); conditional historical SQL execution. Remaining: SCHEMA_PLATFORM, ROLLOUT. +- **V02** — Actual role/RLS enforcement. substantially strengthened. Remaining: STAGING_AUTH. +- **V03** — Real browser account switch. unverified integration boundary. Remaining: STAGING_AUTH. +- **V04** — Durable primary end-to-end workflow. partially covered. Remaining: WORKER_RECOVERY. +- **V05** — Crash/retry/acknowledgement recovery. partially covered. Remaining: WORKER_RECOVERY. +- **V06** — Deletion across all stores. partially covered. Remaining: DATA_LIFECYCLE, RECOVERY. +- **V07** — Mixed version deployment. partially covered. Remaining: ROLLOUT. +- **V08** — Full synthetic backup restore. verified small isolated exercise; hosted recovery open. Remaining: RECOVERY. +- **V09** — Real provider delivery/cancellation/billing. unverified provider boundary. Remaining: WORKER_RECOVERY. +- **V10** — Physical device and installed PWA behavior. unverified physical boundary. Remaining: PHYSICAL_DEVICE. +- **V11** — Representative capacity and cost. unverified capacity guarantee. Remaining: SOAK_CAPACITY. +- **V12** — Normal locked dependency installation. verified local setup. Remaining: No new task: verified local setup strength; keep evidence and do not invent install debt.. +- **V13** — Offline readiness profile. verified local structure. Remaining: CLINICAL_RELEASE. +- **V14** — Alternate Next build output scan. corrected locally. Remaining: PINNED_BROWSER. +- **V15** — Branded Codex Security scan. unavailable capability. Remaining: SECURITY_WORKFLOW. +- **G01** — OpenAI ZDR endpoint/project coverage. owner/provider evidence required. Remaining: PRIVACY. +- **G02** — APP8 cross-border basis. owner/legal evidence required. Remaining: PRIVACY. +- **G03** — APP1/APP5 notices. owner/legal evidence required. Remaining: PRIVACY. +- **G04** — PHI minimisation closeout. partially implemented; approval gap. Remaining: PRIVACY. +- **L01** — Clinical Ask deployed flags/modes/transcription. not a local-code defect. Remaining: CLINICAL_RELEASE. +- **L02** — Hosted feedback migration. hosted evidence required. Remaining: ROLLOUT. +- **L03** — Clinical authority approval. owner/specialist action required. Remaining: CLINICAL_RELEASE. +- **L04** — Seven-mode clinical evaluation. domain validation required. Remaining: CLINICAL_RELEASE. +- **L05** — Protected staging canary. bounded approval and environment required. Remaining: CLINICAL_RELEASE. +- **L06** — Contractual retention/region. provider/legal evidence required. Remaining: PRIVACY. +- **L07** — Physical iPhone acceptance. physical test capability required. Remaining: PHYSICAL_DEVICE. + +## Compatibility and rollout + +Review all eight draft migrations, including the snapshot-fence correction, against the exact target before any merge. Plan schema/app/worker ordering and drain old maintenance and unconditional verification handlers. New versioned RPCs fail closed when missing; older clients can require refresh. Inspect historical timestamp validity before typed overlap queries. The SF01 global-default prerequisite needs platform ownership and cross-schema review. Restore must roll forward to the intended release and reapply retention/deletion policy. Preserve the existing Wait for CI decision and all privacy, source-rights, scanner, canary and specialist holds. + +The two remaining product policy decisions are trusted decoded-audio duration and immediate private-read revocation scope; source-admission authority/provenance is an additional unresolved contract. No answer, approval or provider allowance is inferred from publishing this packet. 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 c240882d74..1e2c936868 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..e1c75edd83 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/20261107000100_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/framework-dependency-modernization-checklist.md b/docs/framework-dependency-modernization-checklist.md index bb222612fc..1c863a359a 100644 --- a/docs/framework-dependency-modernization-checklist.md +++ b/docs/framework-dependency-modernization-checklist.md @@ -293,3 +293,30 @@ route type generation is part of CI, runtime/type targets match, Railway watches every image-build input, focused and broad local gates pass, exact-head hosted CI and review threads are green, and the merged content is verified on `origin/main`. Version bumps alone do not satisfy this definition. + +## 2026-11-05: transitive glob and minimatch security compatibility + +The local audit remediation uses Sentry 11.11.0 and rimraf 6.1.4 within the +existing root ranges. Their supported glob 14 dependency removes the affected +glob 13 path without overriding those callers' declared glob ranges. + +Two parent-scoped overrides select minimatch ^11.0.4 beneath +`@typescript-eslint/typescript-estree` and `readdir-glob`. Their upstream ranges +still request major 10; this is an explicit compatibility decision for the +repository's required Node 24 runtime. The unrelated root minimatch 3 consumer +remains unchanged. Do not replace these with a global major override. + +The actual installed consumers passed the same 32 bounded synthetic cases before +and after resolution: TypeScript project-service admission/refusal, hidden and +nested paths; CJS/ESM directory pattern, ignore and pruning behavior; and real +Archiver glob/directory ZIP entries. The probe uses native package resolution, +not mocked matching. These are Linux compatibility samples, not exhaustive +pattern/platform or application vulnerability guarantees. The audit evidence +package retains the reproducible diagnostic and exact installed-source hashes. + +Keep normal lifecycle-enabled locked installation, installed-lock parity, lint, +typechecking, affected integration tests and both production build paths as +acceptance gates. Reassess and remove each scoped override when its upstream +consumer declares a compatible fixed major, then repeat the consumer probes and +relevant repository gates. Sentry's separately configured hosted delivery and +CodSpeed's embedded library bundle require their own evidence. 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 2a50686328..15795052ba 100644 --- a/docs/organisation/systems/delivery.json +++ b/docs/organisation/systems/delivery.json @@ -183,6 +183,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", @@ -283,6 +284,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 9768e6bd38..d1c73da39a 100644 --- a/docs/organisation/systems/source-intake.json +++ b/docs/organisation/systems/source-intake.json @@ -53,6 +53,7 @@ "scripts/inspect-rendered-pdf.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", @@ -116,6 +117,7 @@ "src/lib/ingestion-enqueue.ts", "src/lib/ingestion-generation-evidence.ts", "src/lib/ingestion-jurisdiction.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/outstanding-issues-inbox/044a35cd-2e56-455b-b742-cfab7318f11b.json b/docs/outstanding-issues-inbox/044a35cd-2e56-455b-b742-cfab7318f11b.json new file mode 100644 index 0000000000..9213dc2be9 --- /dev/null +++ b/docs/outstanding-issues-inbox/044a35cd-2e56-455b-b742-cfab7318f11b.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "id": "044a35cd-2e56-455b-b742-cfab7318f11b", + "createdOn": "2026-11-05", + "action": "add", + "payload": { + "pri": "P2", + "type": "issue", + "summary": "Choose and enforce a trusted server decoded-audio duration limit", + "detail": "2026-11-05 backend audit (F08), local source33f6478: 120second synthetic WAV reaches mocked provider with absent/forged duration metadata. Byte/rate caps exist; no trusted duration cap across accepted compressed formats or declared decoder/runtime contract. Next: Choose supported duration/formats and bounded trusted decoder; verify package/image supply chain and time/memory limits before enforcing decoded duration. Acceptance: Actual synthetic accepted-format boundary, forged metadata, malformed/oversized/decompression/cancellation and decoder failure tests refuse safely before provider work. No WAV-only or client-metadata substitute. Suggested owner: API + Delivery + product owner. No merge, deployment, live migration, provider spending or specialist approval is granted by logging this task.", + "source": "docs/backend-audit-remediation-2026-11-05.md; backend-audit-2026-11-05:AUDIO; source33f6478c63aedf9c2d79effd9890951035f552b2", + "issueUlid": "01M6PNKGB96909Z0GDZ2WPW9NZ" + } +} diff --git a/docs/outstanding-issues-inbox/3871a4f5-fd70-4ebe-a62f-c516271fda12.json b/docs/outstanding-issues-inbox/3871a4f5-fd70-4ebe-a62f-c516271fda12.json new file mode 100644 index 0000000000..343510ab07 --- /dev/null +++ b/docs/outstanding-issues-inbox/3871a4f5-fd70-4ebe-a62f-c516271fda12.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "id": "3871a4f5-fd70-4ebe-a62f-c516271fda12", + "createdOn": "2026-11-05", + "action": "add", + "payload": { + "pri": "P2", + "type": "task", + "summary": "Set private-read and independent bearer/feed revocation semantics", + "detail": "2026-11-05 backend audit (F01, F02), local source33f6478: Immediate private GET revocation policy remains unsettled; established verified-token read fast path and separately issued bearer/feed scope/expiry remain intentional constraints. Next: Specify which private reads need live account bans/revocation, freshness bounds and behavior for independently issued feed/signed credentials; then implement and verify only the selected policy. Acceptance: Real Auth two-user expired/revoked/blocked/session-refresh tests match documented policy across HTTP/cache/export/feed paths. Do not equate sign-out with automatic revocation of every independent credential. Suggested owner: Identity + product owner. No merge, deployment, live migration, provider spending or specialist approval is granted by logging this task.", + "source": "docs/backend-audit-remediation-2026-11-05.md; backend-audit-2026-11-05:READ_POLICY; source33f6478c63aedf9c2d79effd9890951035f552b2", + "issueUlid": "01M6PNKG966WD257F5CM2BFP59" + } +} diff --git a/docs/outstanding-issues-inbox/539e7e69-ea40-4964-957c-d5930fffb77f.json b/docs/outstanding-issues-inbox/539e7e69-ea40-4964-957c-d5930fffb77f.json new file mode 100644 index 0000000000..9435614a8e --- /dev/null +++ b/docs/outstanding-issues-inbox/539e7e69-ea40-4964-957c-d5930fffb77f.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "id": "539e7e69-ea40-4964-957c-d5930fffb77f", + "createdOn": "2026-11-05", + "action": "add", + "payload": { + "pri": "P2", + "type": "task", + "summary": "Verify worker parser isolation and crash/retry/ambiguous-ack recovery on an isolated stack", + "detail": "2026-11-05 backend audit (ING-RAG-01, EXT-ROLES-JOBS-02, EXT-STREAM-01, EXT-STREAM-02, EXT-STREAM-03, F05, F09, R02, V04, V05, V09), local source33f6478: Actual SQL claims/fencing/rollback pass, but full process crash around external effects and acknowledgement, retries, revocation/deletion replay, parser OS/network isolation and cancellation are not established. Next: Prepare owned isolated real-stack fixtures with fault injection and bounded provider test contracts; inspect target/side effects/cost before any provider-backed run. Acceptance: Crash at durable write/external acceptance/ack boundaries; duplicates/out-of-order/revoked owner/stale lease/delete-replay produce intended effects only, bounded retries/cost and diagnosable state. Env stripping/offline flags are not an OS sandbox. Suggested owner: Workers + Database + integrations + Delivery. No merge, deployment, live migration, provider spending or specialist approval is granted by logging this task.", + "source": "docs/backend-audit-remediation-2026-11-05.md; backend-audit-2026-11-05:WORKER_RECOVERY; source33f6478c63aedf9c2d79effd9890951035f552b2", + "issueUlid": "01M6PNKGHTQD32S0PD3AWKFPD9" + } +} diff --git a/docs/outstanding-issues-inbox/7f71bf93-fd2d-4969-ab2c-77f32fd16a94.json b/docs/outstanding-issues-inbox/7f71bf93-fd2d-4969-ab2c-77f32fd16a94.json new file mode 100644 index 0000000000..281a0f40f7 --- /dev/null +++ b/docs/outstanding-issues-inbox/7f71bf93-fd2d-4969-ab2c-77f32fd16a94.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "id": "7f71bf93-fd2d-4969-ab2c-77f32fd16a94", + "createdOn": "2026-11-05", + "action": "add", + "payload": { + "pri": "P2", + "type": "task", + "summary": "Define authenticated provenance admission before restoring new backend document intake", + "detail": "2026-11-05 backend audit (ING-RAG-02), local source33f6478: admit_backend_document is absent; existing admitted resume/force is repaired, new intake now refuses before bytes/writes. Roles/source classes/provenance/review contract is not settled. Next: Owner defines admission authority per source class and mandatory review/provenance; implement atomic idempotent backend admission and entry-point negative/retry tests before enabling new intake. Acceptance: No persisted bytes/job orphan on refusal or partial failure; authenticated owner/resource authority and provenance enforced at durable writer; retries reconcile one intent. Preserve upload-local/rights/scanner/reindex holds. Suggested owner: Source intake + Security + owner. No merge, deployment, live migration, provider spending or specialist approval is granted by logging this task.", + "source": "docs/backend-audit-remediation-2026-11-05.md; backend-audit-2026-11-05:ADMISSION; source33f6478c63aedf9c2d79effd9890951035f552b2", + "issueUlid": "01M6PNKG6YQQ29E94Q02M6GJ89" + } +} diff --git a/docs/outstanding-issues-inbox/869ecf69-784e-4124-94a7-8243083eda15.json b/docs/outstanding-issues-inbox/869ecf69-784e-4124-94a7-8243083eda15.json new file mode 100644 index 0000000000..b3e798b1b5 --- /dev/null +++ b/docs/outstanding-issues-inbox/869ecf69-784e-4124-94a7-8243083eda15.json @@ -0,0 +1,12 @@ +{ + "version": 2, + "id": "869ecf69-784e-4124-94a7-8243083eda15", + "createdOn": "2026-11-05", + "action": "update", + "payload": { + "id": "#057", + "detail": "UPDATE 2026-10-18: the soak, rollback rehearsal and SHA-bound tenancy dispatch are now planned with exact commands, costs, stop conditions and evidence in docs/operator-rehearsal-plan.md steps 3 and 4 (not yet run; owner approval needed). The nightly harness still fails under a green scheduled run (see #F9HZEG). PRIOR DETAIL: Combined test harness code and automated pinned failure routing merged in PR #3087 (7941a7b36f); live staging run pending. PRIOR DETAIL (restored 2026-10-09, an earlier update replaced it wholesale): UPDATE 2026-09-26 (read-only GitHub Actions read, no provider access): the nightly staging-tenancy.yml harness failed on every scheduled run checked (2026-09-21 to 2026-09-25) at its deployment-identity step: staging reported 0b858bb3a (the PR #3030 merge) while the run checked out newer main commits. Staging is not redeployed on pushes to main, so the SHA-bound harness cannot pass on a schedule until staging tracks main or is redeployed before each run. A failing main run now opens a pinned, assigned GitHub issue (label staging-tenancy-failure; branch claude/urgent-ledger-fixes-8dnh5c). The wider harness (CME and On Call writes, evidence, export, content; PR #3065) has not yet run on staging. Next action: owner decides whether staging should auto-deploy from main (Railway setting) or be redeployed before a dispatched run. PRIOR DETAIL: Staging migration parity is closed. Run SHA-bound tenancy evidence, transition the same candidate image from the offline tenancy profile to the authenticated auto-provider load profile, execute the staging soak, and rehearse rollback. Retain checkout/deployed SHA, latency, error, throttling, authentication, cleanup, and rollback evidence. Stop on unsafe data, identity mismatch, or an unowned rollback decision. 2026-11-05 backend audit (F10, F11, V11), local source33f6478: The local small fixtures/build timings are not representative capacity/cost proof. Existing #057 staging deployment alignment/soak/rollback remains pending. Next: Append tested-boundary gaps to #057 and reuse #H69FNZ for hydration attribution; on approved isolated candidate disclose synthetic dataset, concurrency, caches, resource/cost caps and sampling before bounded measurements. Acceptance: Meaningful latency/error distributions/sample counts; no silent lost writes, starvation or unbounded fanout/retries/cancellation work; old/new fleet rollback compatible and observed. Distinguish public metadata/free setup from billable provider work. Suggested owner: Delivery + performance owner + operator. No merge, deployment, live migration, provider spending or specialist approval is granted by logging this task.", + "source": "`docs/launch-operator-runbook.md`; `docs/audit/capacity-review.md`; PR #3087 review; docs/backend-audit-remediation-2026-11-05.md; backend-audit-2026-11-05:SOAK_CAPACITY; source33f6478c63aedf9c2d79effd9890951035f552b2", + "baseRowFingerprint": "c4586f5405481ab205a37c1dee93e6c7077f77d2c1a00195b5048a5c4a958c14" + } +} diff --git a/docs/outstanding-issues-inbox/8901d677-1944-492b-b471-21b2e68a98cc.json b/docs/outstanding-issues-inbox/8901d677-1944-492b-b471-21b2e68a98cc.json new file mode 100644 index 0000000000..9c0dd66040 --- /dev/null +++ b/docs/outstanding-issues-inbox/8901d677-1944-492b-b471-21b2e68a98cc.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "id": "8901d677-1944-492b-b471-21b2e68a98cc", + "createdOn": "2026-11-05", + "action": "add", + "payload": { + "pri": "P2", + "type": "task", + "summary": "Complete historic evidence retention and deletion across derived stores and replay paths", + "detail": "2026-11-05 backend audit (DATA-01, V06), local source33f6478: Prospective permanent deletion now purges17private SQL representations, but historic orphan/abandoned evidence, StorageHTTP removal, worker/queue replay, backups and legal-hold treatment are unverified. No live occupancy/sweep was performed. Next: Agree retention/legal holds and run read-only synthetic-target inventory; design owner-approved cleanup/reconciliation and deletion reapplication after restore. Acceptance: Actual isolated Auth/Storage/worker deletion removes only intended owner data and derivatives; delayed jobs/reindex/restore cannot resurrect or disclose deleted records; legitimate retention is explicit. No live sweep is authorized by task logging. Suggested owner: Data + privacy owner + operations. No merge, deployment, live migration, provider spending or specialist approval is granted by logging this task.", + "source": "docs/backend-audit-remediation-2026-11-05.md; backend-audit-2026-11-05:DATA_LIFECYCLE; source33f6478c63aedf9c2d79effd9890951035f552b2", + "issueUlid": "01M6PNKGFYYK1BX8HGJQYV5AC6" + } +} diff --git a/docs/outstanding-issues-inbox/984893be-f977-4951-b1b3-029155589c8b.json b/docs/outstanding-issues-inbox/984893be-f977-4951-b1b3-029155589c8b.json new file mode 100644 index 0000000000..ab01d1ccf4 --- /dev/null +++ b/docs/outstanding-issues-inbox/984893be-f977-4951-b1b3-029155589c8b.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "id": "984893be-f977-4951-b1b3-029155589c8b", + "createdOn": "2026-11-05", + "action": "add", + "payload": { + "pri": "P3", + "type": "rec", + "summary": "Complete remaining Windows document-copy and dependency-consumer acceptance", + "detail": "2026-11-05 backend audit (RD-01, RD-02, R08), local source33f6478: POSIX0700/0600copies and Linux32consumer cases pass. Windows ACL/path behavior, aggregate preparation peak memory and retained-history disk are outside that proof; scoped overrides and CodSpeed bundle qualification need maintenance. Next: On actual supported Windows/Node24, verify copied-document ACL audience and the bounded native consumer matrix. Measure aggregate resource/history behavior before choosing optional additional limits; review/remove overrides as upstream adopts fixed compatibility. Acceptance: No private ACL broadening or unsafe existing-workspace repair; patterns/ignore/skip and owned deletion keep correct Windows paths; source preserved. Avoid claiming a proven Windows bug or arbitrary-pattern safety. CodSpeed bundle remains a qualification unless actual vulnerable reach is established. Suggested owner: Developer tooling + Windows test owner. No merge, deployment, live migration, provider spending or specialist approval is granted by logging this task.", + "source": "docs/backend-audit-remediation-2026-11-05.md; backend-audit-2026-11-05:WINDOWS_MAINTENANCE; source33f6478c63aedf9c2d79effd9890951035f552b2", + "issueUlid": "01M6PNKH8ES1N8RHHGFQ9MWE9W" + } +} diff --git a/docs/outstanding-issues-inbox/a3ff5894-2b9a-4589-ac74-224bbf68469b.json b/docs/outstanding-issues-inbox/a3ff5894-2b9a-4589-ac74-224bbf68469b.json new file mode 100644 index 0000000000..4a1e381391 --- /dev/null +++ b/docs/outstanding-issues-inbox/a3ff5894-2b9a-4589-ac74-224bbf68469b.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "id": "a3ff5894-2b9a-4589-ac74-224bbf68469b", + "createdOn": "2026-11-05", + "action": "update", + "payload": { + "id": "#QVVS6W", + "summary": "Disaster recovery full-service restore never rehearsed — a small synthetic local database/Vault/object restore passed (2026-11-05); hosted Auth/Storage, production backup and RPO/RTO remain untested", + "detail": "UPDATE 2026-10-18: a step-by-step, timed, owner-approved drill (restore to a new project, Storage copy, count comparison, cost and stop conditions) is drafted in docs/operator-rehearsal-plan.md step 2; it has not run. PRIOR DETAIL: Run one real restore-to-a-new-project drill and record the wall-clock time and what was lost. Why: docs/disaster-recovery-runbook.md states plainly that only the schema layer was rehearsed (2026-07-07, ~19s local container replay) and that the platform restore plus golden retrieval eval has never been executed — so 'we have backups' is currently an untested claim on the live clinical corpus. Distinct from #1K6T35, which is the accepted ~24h recovery-point decision, not whether a restore works at all. Owner: user + operator (provider-backed). Confidence: high — the runbook says so itself. Depends on: #1K6T35 context. 2026-11-05 backend audit (DEL-02, V06, V08), local source33f6478: New6.28MB synthetic restore matches17private fingerprints/Vault/CPD and18local object bytes, but hosted Auth/StorageHTTP, production backup, lost-key and production RPO/RTO remain unverified. Next: Update #QVVS6W title/detail with exact narrow new proof, preserving original plan; schedule separately approved isolated full-service restore, roll forward final migrations and reapply deletion policy. Acceptance: Verified target; restored sign-in/session/storage bytes/download and core workflows usable with owner/other-owner controls; measured time/data loss and cleanup recorded. Preserve accepted ~24hour RPO decision rather than re-opening paid PITR. Suggested owner: Operations + Database + owner. No merge, deployment, live migration, provider spending or specialist approval is granted by logging this task.", + "source": "session 2026-09-20 /ledger sweep — nine-agent repo maturity review; docs/backend-audit-remediation-2026-11-05.md; backend-audit-2026-11-05:RECOVERY; source33f6478c63aedf9c2d79effd9890951035f552b2", + "baseRowFingerprint": "e4c50da22cfb0285829deb10c9f78236b5f311db99778a0aebee0b0832c11fc1", + "targetUlid": "01M2ZHJ750QVVS6WX5AYF21BNA" + } +} diff --git a/docs/outstanding-issues-inbox/a9bb0fe1-8342-4f9b-8189-b16ec08d9eb5.json b/docs/outstanding-issues-inbox/a9bb0fe1-8342-4f9b-8189-b16ec08d9eb5.json new file mode 100644 index 0000000000..cd6cf8d9ff --- /dev/null +++ b/docs/outstanding-issues-inbox/a9bb0fe1-8342-4f9b-8189-b16ec08d9eb5.json @@ -0,0 +1,12 @@ +{ + "version": 2, + "id": "a9bb0fe1-8342-4f9b-8189-b16ec08d9eb5", + "createdOn": "2026-11-05", + "action": "update", + "payload": { + "id": "#055", + "detail": "REVIEW 2026-09-25 (offline audit of current main; no provider access): Still pending. Dependency: #9Z197J (2 Firefox and several WebKit release-matrix failures still open), so the Firefox/WebKit leg cannot pass today. PRIOR DETAIL: Before the next full-confidence release/handoff, record the candidate/PR SHA and run the local/provider release gates, Firefox/WebKit, required hosted CI, and actionable GitHub review-thread closure once. Stop at the first actionable failure and rerun only the repaired smallest gate. 2026-11-05 backend audit (ING-RAG-03, V13, L01, L03, L04, L05), local source33f6478: Existing #055 release gate is open. Deployed mode/flag/transcription/feedback-schema state, named clinical authority, seven-mode evaluation and protected staging canary are not established by deterministic offline checks. Next: Append L01-L05 acceptance to #055, preserve existing cross-browser/CI/review requirements and #TXVBAZ accepted diagnosis/treatment constraints. Obtain only the missing specific approvals/bounded evaluation authority. Acceptance: Exact SHA/mode/config/provider-target evidence, real feedback migration presence, dated clinical authority, seven-mode usefulness/safety evaluation and approved canary meet unchanged gates. No flags/content/ranking/protected fixture changes or forged approvals. Suggested owner: Owner + clinical authority + release operator. No merge, deployment, live migration, provider spending or specialist approval is granted by logging this task.", + "source": "`docs/launch-operator-runbook.md`; `docs/codex-review-protocol.md`; docs/backend-audit-remediation-2026-11-05.md; backend-audit-2026-11-05:CLINICAL_RELEASE; source33f6478c63aedf9c2d79effd9890951035f552b2", + "baseRowFingerprint": "73916c970b936e1301b658eb863dfa47c0c6edd91b6f11e7ad64c2b2ded3b7a7" + } +} diff --git a/docs/outstanding-issues-inbox/b3044672-a6dd-4bfa-81ad-a057b7c0b810.json b/docs/outstanding-issues-inbox/b3044672-a6dd-4bfa-81ad-a057b7c0b810.json new file mode 100644 index 0000000000..3019bf4edb --- /dev/null +++ b/docs/outstanding-issues-inbox/b3044672-a6dd-4bfa-81ad-a057b7c0b810.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "id": "b3044672-a6dd-4bfa-81ad-a057b7c0b810", + "createdOn": "2026-11-05", + "action": "add", + "payload": { + "pri": "P2", + "type": "task", + "summary": "Complete physical iPhone Safari and installed-PWA microphone/task acceptance", + "detail": "2026-11-05 backend audit (V10, L07), local source33f6478: No physical device/assistive-technology/installed-PWA acceptance occurred. Chromium viewport flows do not establish Safari microphone, software keyboard, offline lifecycle or assistive conformance. Next: Use synthetic inputs on an approved exact build; test actual iPhone Safari and installedPWA with permission/error/interruption/keyboard/overlay/focus/account-switch states. Acceptance: Record hardware/OS/build/config and user roles; microphone consent/interruption/cancellation and task recovery work with no sensitive data or downstream cost outside approved scope. Keep conformance claims proportional. Suggested owner: Product/device tester + clinical owner. No merge, deployment, live migration, provider spending or specialist approval is granted by logging this task.", + "source": "docs/backend-audit-remediation-2026-11-05.md; backend-audit-2026-11-05:PHYSICAL_DEVICE; source33f6478c63aedf9c2d79effd9890951035f552b2", + "issueUlid": "01M6PNKH2H93E7YJ2DDVK9PGSP" + } +} diff --git a/docs/outstanding-issues-inbox/b7a42470-1b20-402c-a85e-d255fd6e6d87.json b/docs/outstanding-issues-inbox/b7a42470-1b20-402c-a85e-d255fd6e6d87.json new file mode 100644 index 0000000000..3f9cf266a4 --- /dev/null +++ b/docs/outstanding-issues-inbox/b7a42470-1b20-402c-a85e-d255fd6e6d87.json @@ -0,0 +1,13 @@ +{ + "version": 2, + "id": "b7a42470-1b20-402c-a85e-d255fd6e6d87", + "createdOn": "2026-11-05", + "action": "update", + "payload": { + "id": "#CK9GBW", + "detail": "Owner decision 2026-10-24 (docs/decisions/2026-10-24-worker-scan-advisory-until-2-dec.md): the worker image scan stays advisory on main until 2026-12-02 instead of 2026-11-04, because Trivy listed no fixed version for any of the 24 findings in GitHub issue #3741 (libxml2 CVE-2026-6653 CRITICAL first). Each week, read the daily docker-image.yml scan summary for clinical-kb-worker:ci and bump any package that now has a fixed Debian or pip release. Before 2 Dec: either the findings are fixed or suppressed with a signed-off not-affected decision, or the owner decides again. Hand-patching (backports) waits until the three-document re-index pilot is finished. 2026-11-05 backend audit (R01, R03), local source33f6478: Current Docling2.139 caps Pillow<13; candidate2.142 permits13 but model/dependency/image compatibility is not verified. Python3.11 works; one new host-root probe meets proxyCONNECT403, earlier per-model errors remain individually unclassified. Next: Append current source facts to #CK9GBW while preserving its24finding history and2December policy. Establish permitted artifact route and actual resource bounds, regenerate hash-correct candidate lock/model tree, run real raster-PDF/OCR and image assessment. Acceptance: No guessed hashes/model identities, suppression expiry renewal, scan threshold change or clinical activation. Exact candidate runtime/models/normal image controls pass and fresh accepted/advisory applicability is recorded. Suggested owner: Extraction + Delivery/security owner. No merge, deployment, live migration, provider spending or specialist approval is granted by logging this task.", + "source": "Catch-up review thread 2026-10-24, decision card; GitHub issue #3741; docs/backend-audit-remediation-2026-11-05.md; backend-audit-2026-11-05:DEPENDENCY_IMAGE; source33f6478c63aedf9c2d79effd9890951035f552b2", + "baseRowFingerprint": "c816210ffca2726aceba032750453773f3332059a2645ddf432f090974e9c1ce", + "targetUlid": "01M5Q907D1CK9GBW2B79ZMYEXQ" + } +} diff --git a/docs/outstanding-issues-inbox/d32690bf-8a80-4582-9d5c-b06e17badb64.json b/docs/outstanding-issues-inbox/d32690bf-8a80-4582-9d5c-b06e17badb64.json new file mode 100644 index 0000000000..7807443d9d --- /dev/null +++ b/docs/outstanding-issues-inbox/d32690bf-8a80-4582-9d5c-b06e17badb64.json @@ -0,0 +1,12 @@ +{ + "version": 2, + "id": "d32690bf-8a80-4582-9d5c-b06e17badb64", + "createdOn": "2026-11-05", + "action": "update", + "payload": { + "id": "#MXHK8F", + "detail": "DEFERRED 2026-10-27 by the owner pending a Supabase support ticket. Live read on 2026-10-27 (owner-run SELECT on pg_default_acl): supabase_admin in schema public still has default privileges granting anon and authenticated full access to new tables (arwdDxtm), sequences (rwU) and functions (X). No existing object is affected; the risk is any future object created as supabase_admin (for example by a platform tool) starting open to the public API key. Migration 20260713102000_revoke_supabase_admin_default_privileges degrades to a warning on hosted because postgres is not a member of supabase_admin, and the owner's manual run in the SQL editor on 2026-10-27 returned permission denied. NEXT ACTION: the owner sends Supabase support a ticket for project sjrfecxgysukkwxsowpy asking them to run: alter default privileges for role supabase_admin in schema public revoke all privileges on tables from anon, authenticated; ... revoke usage, select on sequences from anon, authenticated; ... revoke execute on functions from public, anon, authenticated. VERIFY: select defaclobjtype, defaclacl from pg_default_acl where defaclrole = 'supabase_admin'::regrole and defaclnamespace = 'public'::regnamespace returns no anon or authenticated entries. UNTIL THEN: create tables only through repository migrations, never by hand in the dashboard. The other 12 default grants seen (storage, graphql, graphql_public, supabase_functions) are platform-owned and expected. 2026-11-05 backend audit (DATA-02, R07, V01), local source33f6478: Existing support task is already pending and owner-deferred. Isolated bare-image replay additionally shows globalPUBLICfunction default cannot be subtracted by per-schema revoke; hosted global state was not read. Next: Append the new isolated global-default diagnosis to the existing pending add via targetUlid; preserve its original owner support deferral and schema-specific facts. Have platform owner inspect namespace0/global and schema ACLs before proposing broader changes. Acceptance: Original143payload success/144thSF01 failure retained; stronger synthetic continuation is conditional, not faithful CLI/hosted replay. Any global correction is platform-approved with cross-schema compatibility review; no reserved-role escalation/history repair/allowlist widening. Suggested owner: Supabase platform owner + Database. No merge, deployment, live migration, provider spending or specialist approval is granted by logging this task.", + "source": "S1 live privacy checks thread, 2026-10-27; migration 20260713102000; docs/backend-audit-remediation-2026-11-05.md; backend-audit-2026-11-05:SCHEMA_PLATFORM; source33f6478c63aedf9c2d79effd9890951035f552b2", + "targetUlid": "01M5YEVA3ZMXHK8FS728MBZ1Q3" + } +} diff --git a/docs/outstanding-issues-inbox/dce12b53-ef7e-44b1-94ff-98a6ed9fe36a.json b/docs/outstanding-issues-inbox/dce12b53-ef7e-44b1-94ff-98a6ed9fe36a.json new file mode 100644 index 0000000000..b0f6ca2623 --- /dev/null +++ b/docs/outstanding-issues-inbox/dce12b53-ef7e-44b1-94ff-98a6ed9fe36a.json @@ -0,0 +1,13 @@ +{ + "version": 2, + "id": "dce12b53-ef7e-44b1-94ff-98a6ed9fe36a", + "createdOn": "2026-11-05", + "action": "update", + "payload": { + "id": "#F9HZEG", + "detail": "REVALIDATED 2026-10-23 at df1e07db04. The harness already includes broader domains; the missing result is successful dispatched two-user staging proof. Preserve the half-built staging blocker and do not count tolerated scheduled failure as a pass. Source inspection only; no fresh application/provider execution or clinical sign-off. Evidence: .github/workflows/staging-tenancy.yml; docs/staging-tenancy-release-evidence.md. PRIOR DETAIL: UPDATE 2026-10-18 (GitHub Actions run read reported by the coordinating session; no provider access from this branch): the nightly staging-tenancy.yml workflow shows success, but its harness step actually fails, because continue-on-error applies on schedule runs. Run 39059127896 on 2026-10-17 logged 'GET /api/on-call/services returned 503'. So CME and On Call two-user isolation is still unproven on staging, and stays unproven until staging tracks main or is redeployed to the run's SHA (#057). Next action: deploy one exact candidate to staging and dispatch the workflow from that SHA (a dispatched run fails loudly), per docs/operator-rehearsal-plan.md step 3a. PRIOR DETAIL: UPDATE 2026-10-06: Two-user CME/On Call write probe offline test suite tests/cross-tenant-write-probe.test.ts fully passing (9/9 tests). Owner authorized live staging execution via staging-tenancy.yml workflow dispatch. 2026-11-05 backend audit (ACCESS-01, ACCESS-02, ACCESS-03, DP-01, DP-02, DP-03, DP-04, EXT-ROLES-JOBS-01, F03, F04, F06, F07, V02, V03), local source33f6478: Component/SQL roles are strong local evidence; six guest flows do not exercise real Auth/JWT/HTTP ownership or two signed-in identities. Existing staging rebuild/SHA alignment gap remains. Next: Append audit acceptance to #F9HZEG, retaining existing staging blocker; use exact deployed candidate and two synthetic users/roles to exercise delayed account switch, stale/refused mutations and privileged/anonymous/cross-owner paths. Acceptance: Identity/deployedSHA verified; same-owner success, other-owner/nonexistent/anonymous/denied role refusal, stale callbacks/cache/exports after switch/revocation tested at enforcement owner. No success credited from tolerated scheduled failure. Suggested owner: Identity + app/Teaching owners + staging operator. No merge, deployment, live migration, provider spending or specialist approval is granted by logging this task.", + "source": "Current-state issue revalidation 2026-10-23 at df1e07db04; Ultimate Audit Master Report 2026-09-25 (owner-supplied .docx), reviewed offline against main 955996146 in session claude/review-assessment-4s1w86; no provider access; docs/backend-audit-remediation-2026-11-05.md; backend-audit-2026-11-05:STAGING_AUTH; source33f6478c63aedf9c2d79effd9890951035f552b2", + "baseRowFingerprint": "25aff693470a24540e51f740ac19799fd118477518e44677476878fcb1243912", + "targetUlid": "01M3CHC1JDF9HZEG738N2MBN2W" + } +} diff --git a/docs/outstanding-issues-inbox/dd15ac8c-3ea7-4b63-ad95-deae8c36faa8.json b/docs/outstanding-issues-inbox/dd15ac8c-3ea7-4b63-ad95-deae8c36faa8.json new file mode 100644 index 0000000000..b9e19ab933 --- /dev/null +++ b/docs/outstanding-issues-inbox/dd15ac8c-3ea7-4b63-ad95-deae8c36faa8.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "id": "dd15ac8c-3ea7-4b63-ad95-deae8c36faa8", + "createdOn": "2026-11-05", + "action": "add", + "payload": { + "pri": "P1", + "type": "task", + "summary": "Review and safely roll out the audited app/worker fixes and eight draft migrations", + "detail": "2026-11-05 backend audit (ACCESS-01, ING-RAG-01, ACCESS-02, ACCESS-03, DATA-01, DATA-02, DEL-01, DP-01, DP-02, DP-03, DP-04, EXT-ROLES-JOBS-01, EXT-ROLES-JOBS-02, EXT-STREAM-01, EXT-STREAM-02, ING-RAG-03, EXT-STREAM-03, F03, F04, F05, F06, F07, F09, F11, R05, R07, V01, V07, L02), local source33f6478: Local corrections are committed and checked; target deployment, supported CLI/journal replay and old/new fleet acceptance are unverified. Opening a PR does not authorize merge, live SQL or deployment. Next: Review candidate PR and all eight immutable forward drafts (including 20261107000800_audit_fix_maintenance_snapshot_fence_aliases.sql, which corrects the maintenance snapshot fences; the earlier seven-migration parity evidence predates it), rerun parity/replay with the eighth, verify exact target/project/default-ACL prerequisites, rehearse supported replay and mixed versions, quiesce old maintenance/unconditional verification handlers; obtain separate rollout authority. Acceptance: Correct owner/role validation, actual hosted RPC contracts, private drift coverage, malformed historical booking timestamp preflight, stale/concurrent mutation refusal, calendar rollover and versioned-client failure behavior; no old unsafe writer persists. No protected clinical content/flags/history edits. Suggested owner: Delivery + Database + owner. No merge, deployment, live migration, provider spending or specialist approval is granted by logging this task.", + "source": "docs/backend-audit-remediation-2026-11-05.md; backend-audit-2026-11-05:ROLLOUT; source33f6478c63aedf9c2d79effd9890951035f552b2", + "issueUlid": "01M6PNKG4N36XZRQH264DS035C" + } +} diff --git a/docs/outstanding-issues-inbox/e1ca65c7-e74e-4abb-8c42-f9e1a5c5932b.json b/docs/outstanding-issues-inbox/e1ca65c7-e74e-4abb-8c42-f9e1a5c5932b.json new file mode 100644 index 0000000000..d1329ac4df --- /dev/null +++ b/docs/outstanding-issues-inbox/e1ca65c7-e74e-4abb-8c42-f9e1a5c5932b.json @@ -0,0 +1,13 @@ +{ + "version": 2, + "id": "e1ca65c7-e74e-4abb-8c42-f9e1a5c5932b", + "createdOn": "2026-11-05", + "action": "update", + "payload": { + "id": "#WR6FAW", + "detail": "Established 2026-09-20 by running the three failures against a clean origin/main worktree (aef181f55) with the same environment, and reading the saved failure snapshots. They reproduce identically on main, so they are a property of the sandbox, not of any branch. ROOT CAUSE, found rather than guessed: the only installable browser here is /opt/pw-browsers/chromium = chromium-1194 = Chromium 141.0.7390.37, because cdn.playwright.dev is 403 under the egress policy and the revision Playwright 1.63.0 pins is 1243 / Chromium 153. (1) tests/ui-smoke.spec.ts \"document frame stretches the canvas owner at phone and desktop\" and \"document viewer puts the PDF preview first with pinned evidence after it on mobile\" both fail with getByTestId(pdf-canvas-scroll).locator(canvas) \"element(s) not found\". The error-context snapshot shows the viewer in its error state with the text \"this[#re].getOrInsertComputed is not a function\" and a Retry preview button. pdfjs-dist@6.3.289 calls Map.prototype.getOrInsertComputed, a V8 addition Chromium 141 does not have, so pdf.js throws during load and no canvas is ever created. The demo PDFs and cover images are all present; nothing is missing from the fixture. (2) tests/ui-pwa.spec.ts \"has a browser-valid manifest, installable icons, and a root worker\" fails with installability error \"in-incognito\" — Chromium refusing because every Playwright BrowserContext is off-the-record. Every other assertion in that test (manifest URL, manifest contents, all icon probes, service-worker scope/state/controller) passes first. CONSEQUENCE FOR AGENTS: three failures out of ~798 passing is the FLOOR for a chromium browser gate in this environment, on any branch. Do not read it as a branch signal, and do not \"fix\" a test to make it pass here. CI runs the pinned browser and the Production UI chromium shards are green on recent main (7936c3f9, cfb5ed90, and shard 3 on aef181f55). NOT ESTABLISHED: per-test CI granularity, because gh run view --log is Forbidden here (results-receiver.actions.githubusercontent.com); only job-level green was read. That Chromium 153 stops reporting in-incognito is an inference from CI being green, not a direct observation. TO UNBLOCK: either allowlist cdn.playwright.dev so the pinned revision installs, or teach the browser gate to recognise this known trio and report them as environment-floor rather than failures. 2026-11-05 backend audit (DEL-03, V14), local source33f6478: Playwright1.64.1 requiresChromium156revision1248; official download403. Six currentChromium151guest flows pass, but historical three-failure floor belongs to an older host/browser and is not independently established here. Next: Append current source/runtime evidence to #WR6FAW and link existing #PM71Z1/pending host update rather than another competing mutation; obtain official pinned runtime on a permitted host and run unchanged selected UI gates. Acceptance: Exact candidate/build/session/role/browser identity; required pinned proof passes without changing assertions, pins or host security. Do not infer physical Safari/PWA/Auth/conformance from151emulation. Suggested owner: Delivery + test environment owner. No merge, deployment, live migration, provider spending or specialist approval is granted by logging this task.", + "source": "Claude Code session 2026-09-20 (session_01VE1C4eJidBwMdfHV4nUJgL); reproduced on a clean origin/main worktree with the same runner and environment; docs/backend-audit-remediation-2026-11-05.md; backend-audit-2026-11-05:PINNED_BROWSER; source33f6478c63aedf9c2d79effd9890951035f552b2", + "baseRowFingerprint": "0ddc60539589c5ecbb05ed8d38d3a1332147c709cfeb87cd78c97475bcae94a4", + "targetUlid": "01M2ZRFAJRWR6FAWA79TE566QG" + } +} diff --git a/docs/outstanding-issues-inbox/ebe37fb2-e909-4748-8af9-088ac635e90f.json b/docs/outstanding-issues-inbox/ebe37fb2-e909-4748-8af9-088ac635e90f.json new file mode 100644 index 0000000000..8225860f56 --- /dev/null +++ b/docs/outstanding-issues-inbox/ebe37fb2-e909-4748-8af9-088ac635e90f.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "id": "ebe37fb2-e909-4748-8af9-088ac635e90f", + "createdOn": "2026-11-05", + "action": "add", + "payload": { + "pri": "P2", + "type": "task", + "summary": "Verify deployed failure and recurring-error visibility under the daily health-summary policy", + "detail": "2026-11-05 backend audit (DEL-01, R06), local source33f6478: False-green monitor summary is repaired locally; current deployed notification delivery, recurring Sentry-group coverage and release-correlated diagnosis remain unverified. New-group webhook semantics alone do not prove recurrence detection. Next: Verify exact current app/worker/release selectors and alert configuration, then use approved harmless synthetic failures to prove failed/delayed/partial/recurrent operations reach the existing daily digest and operator. Acceptance: A known failed/unavailable run never renders healthy; recurrence is visible under the agreed7amPerth digest policy; diagnosis links request/job/release without sensitive payloads. Do not enable parked sourcemap uploads or send real notifications without separate authority. Suggested owner: Delivery + operations owner. No merge, deployment, live migration, provider spending or specialist approval is granted by logging this task.", + "source": "docs/backend-audit-remediation-2026-11-05.md; backend-audit-2026-11-05:OPS_MONITOR; source33f6478c63aedf9c2d79effd9890951035f552b2", + "issueUlid": "01M6PNKGWB5EQXXF62EB11A1AS" + } +} diff --git a/docs/outstanding-issues-inbox/ef7c5dcf-1145-4611-81a6-f10784c2f6e6.json b/docs/outstanding-issues-inbox/ef7c5dcf-1145-4611-81a6-f10784c2f6e6.json new file mode 100644 index 0000000000..7d57f76d2a --- /dev/null +++ b/docs/outstanding-issues-inbox/ef7c5dcf-1145-4611-81a6-f10784c2f6e6.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "id": "ef7c5dcf-1145-4611-81a6-f10784c2f6e6", + "createdOn": "2026-11-05", + "action": "add", + "payload": { + "pri": "P3", + "type": "rec", + "summary": "Record remaining specialised security-workflow acceptance when supported capability is available", + "detail": "2026-11-05 backend audit (V15), local source33f6478: Required referenced Codex Security core/preflight capability was unavailable; no branded scan was registered or completed. Native threat/source/runtime review is separate valid evidence. Next: Assess whether a supported specialised workflow adds material assurance; if selected, verify prerequisites/target and bounded runtime/spend before one canonical scan. Otherwise retain explicit native-review scope and unavailable-branded evidence. Acceptance: No fabricated canonical artifacts/equivalence or duplicate uncertain scans; any chargeable run has separately approved total/cap. This is an assurance recommendation, not a confirmed software vulnerability. Suggested owner: Security owner. No merge, deployment, live migration, provider spending or specialist approval is granted by logging this task.", + "source": "docs/backend-audit-remediation-2026-11-05.md; backend-audit-2026-11-05:SECURITY_WORKFLOW; source33f6478c63aedf9c2d79effd9890951035f552b2", + "issueUlid": "01M6PNKH6Q6PT2171AVBMM3S3W" + } +} diff --git a/docs/scripts-index.md b/docs/scripts-index.md index 5c599f3071..da8cd6da83 100644 --- a/docs/scripts-index.md +++ b/docs/scripts-index.md @@ -1,6 +1,6 @@ # Scripts index -Curated map of `scripts/` (457 files) and the `package.json` script surface (336 entries), +Curated map of `scripts/` (463 files) and the `package.json` script surface (336 entries), grouped by purpose. This is orientation, not an exhaustive per-file listing — the authoritative command list is `package.json`, and `npm run docs:check-scripts` verifies every `npm run ` referenced in docs resolves to a real script. `npm run docs:update` refreshes the exact counts above. diff --git a/docs/worker-deploy-runbook.md b/docs/worker-deploy-runbook.md index 9a8a564733..98ea03493a 100644 --- a/docs/worker-deploy-runbook.md +++ b/docs/worker-deploy-runbook.md @@ -583,6 +583,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/package-lock.json b/package-lock.json index 0154ef0bc1..2c001e5cdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1988,6 +1988,12 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@isaacs/nonrecursive-glob-match": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.0.3.tgz", + "integrity": "sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==", + "license": "BlueOak-1.0.0" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -3714,30 +3720,30 @@ "license": "MIT" }, "node_modules/@sentry/browser": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-11.10.0.tgz", - "integrity": "sha512-kU8Inx3K/PaaDGqTu4lBW8XCYNsYNTwwPIZNIRDas/mTXtepXpM7M0KxxrTSZXBTWX0RfDU4t7vsL9ay0o4+NQ==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-11.11.0.tgz", + "integrity": "sha512-UEq04DIcfCVUMF+T0rH9nLTEjVlymmZC8D/7JELJyZkpxMCtnf8iq3aPJ3DXICDCjSo7CuuOI9OIZ32KmmsyPA==", "license": "MIT", "dependencies": { - "@sentry/browser-utils": "11.10.0", - "@sentry/conventions": "^0.29.0", - "@sentry/core": "11.10.0", - "@sentry/feedback": "11.10.0", - "@sentry/replay": "11.10.0", - "@sentry/replay-canvas": "11.10.0" + "@sentry/browser-utils": "11.11.0", + "@sentry/conventions": "^0.30.0", + "@sentry/core": "11.11.0", + "@sentry/feedback": "11.11.0", + "@sentry/replay": "11.11.0", + "@sentry/replay-canvas": "11.11.0" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" } }, "node_modules/@sentry/browser-utils": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-11.10.0.tgz", - "integrity": "sha512-P/bYHTgj76sYBYCZa524YoZ/hZac2XCW3i3qShrMXnh7s63x1cweUjD7BDVDb0L/lGMwtuObuZeRnCif4hWLNg==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-11.11.0.tgz", + "integrity": "sha512-sL1BK7Z7ubIBp+cnz5VIQcVPn+t4PgWTWYaK/AWklPzeA75CJ5xeeCrx0RUvg65k0BbUcc/XvWVxg5v73RQqiQ==", "license": "MIT", "dependencies": { - "@sentry/conventions": "^0.29.0", - "@sentry/core": "11.10.0", + "@sentry/conventions": "^0.30.0", + "@sentry/core": "11.11.0", "web-vitals": "^6.2.3" }, "engines": { @@ -3745,14 +3751,14 @@ } }, "node_modules/@sentry/bundler-plugins": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/bundler-plugins/-/bundler-plugins-11.10.0.tgz", - "integrity": "sha512-+ngJ485rIYll5uOhWSvSO+Vx4S24MGe5KcLKlUZghr+BXK+ib32uBOZNG294wGyeauLTDvdjncLEYwiaBnP/5w==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/bundler-plugins/-/bundler-plugins-11.11.0.tgz", + "integrity": "sha512-et1v8V74ARNOSQ+k4VG7PRiNJl2APGAzlRCiUt42izw8yD+X+E4oCe7wwHjohmv3zufvD1VntHDptSC0/9ChQw==", "license": "MIT", "dependencies": { - "@sentry/core": "11.10.0", + "@sentry/core": "11.11.0", "dotenv": "^17.4.2", - "glob": "^13.0.6", + "glob": "^14.0.3", "magic-string": "~0.30.8 || ^1.0.0", "oxc-parser": "^0.152.0", "sentry": "^0.45.0", @@ -4163,16 +4169,16 @@ } }, "node_modules/@sentry/cloudflare": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/cloudflare/-/cloudflare-11.10.0.tgz", - "integrity": "sha512-S9x1ZFHnM1BLh2FVzCKO+2QLG0rCtLsZjYn3ZhHfyT6RN3jwh73So1RT/gE1dsAZj+xq8lr2ptTPmv/ATsYc7w==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/cloudflare/-/cloudflare-11.11.0.tgz", + "integrity": "sha512-N3s9PjTDNvkIDFdoTbPtJnJsxDVUPvd0AEh/aMtZaqJK90vk2yULpP7VjU96nCIl4VMwc8E/YpNbPyBX0eAxhQ==", "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/conventions": "^0.29.0", - "@sentry/core": "11.10.0", - "@sentry/opentelemetry": "11.10.0", - "@sentry/server-utils": "11.10.0", + "@sentry/conventions": "^0.30.0", + "@sentry/core": "11.11.0", + "@sentry/opentelemetry": "11.11.0", + "@sentry/server-utils": "11.11.0", "magic-string": "~0.30.21" }, "engines": { @@ -4196,57 +4202,57 @@ } }, "node_modules/@sentry/conventions": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.29.0.tgz", - "integrity": "sha512-kYq9/HJ/l6MYP/srIc20dy1W4mWyJfCF0OWFF+RhBGZwyr/wCmtabRFKyw7kqM+DTwZXQOjSTSZUcJhDowcInQ==", + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.30.0.tgz", + "integrity": "sha512-WTumd5A2iFmHTRubi6mc6gLHU8LswPQqoNaZu6T1cxiTUmy3WdEw0i8FAnVbcgxTlsumX5oh63/b4B9lY5wAPQ==", "license": "MIT", "engines": { "node": ">=14" } }, "node_modules/@sentry/core": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-11.10.0.tgz", - "integrity": "sha512-l/OtTo1v+TQgZtHG2Q5N4IX+gFdAE1tZ27K/P21UCzJglWk54TPEm+d9T2lulPqMK06kZcLwDgRAhE2GO4YS5w==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-11.11.0.tgz", + "integrity": "sha512-PRrcEdPrTYuHcbDa6DhJZR9Qg2d+2gjKVXiv2QeiTgxvhvXDxYiaVSXH0lldKTR099Er5TAEChiX7aEu4yNlUw==", "license": "MIT", "dependencies": { - "@sentry/conventions": "^0.29.0" + "@sentry/conventions": "^0.30.0" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" } }, "node_modules/@sentry/feedback": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-11.10.0.tgz", - "integrity": "sha512-jZqxVHPoUeZ6W7xSlhJdTMuM/GUJLA/K4U9OFmkP+g+TvtXhdwWxeiT23m2Wm5NmTHjaB07jSc05ayuflD8ETw==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-11.11.0.tgz", + "integrity": "sha512-KP1AYilzLYLIDcDiUD8eaEV955x5rKnwdxpXE70sJpuHUWuEB28MyyDe+tPWU8/MZtdmFGrqCxRqGQN9dNuvtw==", "license": "MIT", "dependencies": { - "@sentry/core": "11.10.0" + "@sentry/core": "11.11.0" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" } }, "node_modules/@sentry/nextjs": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/nextjs/-/nextjs-11.10.0.tgz", - "integrity": "sha512-P8qwwwS4RHDOXL4G+tu2kacAzrPzD7nJjvwHBzmOSg/+yW+/6NobQ2u2yesXWRkynRw6dv5+w76UWaheJq+vUQ==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/nextjs/-/nextjs-11.11.0.tgz", + "integrity": "sha512-JbdOylp8K2ImT/zihYwJUiwhfjZ1c1PKpzEy+bT4+meA5+Shzwlkzk3X/BVOr1i4nBl4iYg6VtpVdWa6bxeYTw==", "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.1", "@rollup/plugin-commonjs": "28.0.1", - "@sentry/browser-utils": "11.10.0", - "@sentry/bundler-plugins": "11.10.0", - "@sentry/cloudflare": "11.10.0", - "@sentry/conventions": "^0.29.0", - "@sentry/core": "11.10.0", - "@sentry/node": "11.10.0", - "@sentry/opentelemetry": "11.10.0", - "@sentry/react": "11.10.0", - "@sentry/server-runtime-injection": "11.10.0", - "@sentry/server-utils": "11.10.0", - "@sentry/vercel-edge": "11.10.0", + "@sentry/browser-utils": "11.11.0", + "@sentry/bundler-plugins": "11.11.0", + "@sentry/cloudflare": "11.11.0", + "@sentry/conventions": "^0.30.0", + "@sentry/core": "11.11.0", + "@sentry/node": "11.11.0", + "@sentry/opentelemetry": "11.11.0", + "@sentry/react": "11.11.0", + "@sentry/server-runtime-injection": "11.11.0", + "@sentry/server-utils": "11.11.0", + "@sentry/vercel-edge": "11.11.0", "rollup": "^4.60.3", "stacktrace-parser": "^0.1.11" }, @@ -4258,31 +4264,31 @@ } }, "node_modules/@sentry/node": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-11.10.0.tgz", - "integrity": "sha512-ZsndrSn1NjL9AslpUdxz/kYPcv/i+plVZoyPe1ivlr5XPUpocB8pUL5noYbIUl96Wd/yGvPBw6zqO8d69rRKQQ==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-11.11.0.tgz", + "integrity": "sha512-6hbFui4AwP/1KGQD7wgvG0rjve4vgb9x+cBAaHV2N7YwZDZUoKdVaJr/S4PYByuI6UPR8xtwGluFlMFXOScz+Q==", "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/bundler-plugins": "11.10.0", - "@sentry/conventions": "^0.29.0", - "@sentry/core": "11.10.0", - "@sentry/opentelemetry": "11.10.0", - "@sentry/server-runtime-injection": "11.10.0", - "@sentry/server-utils": "11.10.0" + "@sentry/bundler-plugins": "11.11.0", + "@sentry/conventions": "^0.30.0", + "@sentry/core": "11.11.0", + "@sentry/opentelemetry": "11.11.0", + "@sentry/server-runtime-injection": "11.11.0", + "@sentry/server-utils": "11.11.0" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" } }, "node_modules/@sentry/opentelemetry": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-11.10.0.tgz", - "integrity": "sha512-YMV4ZgaRlW7QPCcMScVpSBvYOdvDlY+alqRT3KOTcmPKFZtf8O2hXHWFhl/M33kKDEfqOk2XtEVeHs3mZFOCaw==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-11.11.0.tgz", + "integrity": "sha512-L1lXAF1hNUSOTLG8NLkBaopvWWpXqlYgQ++cfWr8Gw4wdmReNp9ec533bEj4r6ZJ6PvIYNdIVNI22GdvmbP6xg==", "license": "MIT", "dependencies": { - "@sentry/conventions": "^0.29.0", - "@sentry/core": "11.10.0" + "@sentry/conventions": "^0.30.0", + "@sentry/core": "11.11.0" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" @@ -4292,14 +4298,14 @@ } }, "node_modules/@sentry/react": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/react/-/react-11.10.0.tgz", - "integrity": "sha512-xRa0LTxAu/wW6Q/prOncsfLV4ovmFfwcoLR1xmReWFCTVSYdh9+qbS6fnR3yHGmZUQgGPY9veBsaYGveYoqqQw==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-11.11.0.tgz", + "integrity": "sha512-c0Z+MPFIahsFximTIq4kmxldKY9XG1FWmIgblosRxqd5xEj27/TbfZsv2/yYg6jMNVvSkYwbZ3nm7xoBryGZWw==", "license": "MIT", "dependencies": { - "@sentry/browser": "11.10.0", - "@sentry/conventions": "^0.29.0", - "@sentry/core": "11.10.0" + "@sentry/browser": "11.11.0", + "@sentry/conventions": "^0.30.0", + "@sentry/core": "11.11.0" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" @@ -4315,67 +4321,67 @@ } }, "node_modules/@sentry/replay": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-11.10.0.tgz", - "integrity": "sha512-0rcg+43tkO278VyrKcbmg0JQm2yxuguXKLbr9ieiNsynaxj22JC8baie4Uxizf2jM2ukfqA3O+o0Pbqaed8P2Q==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-11.11.0.tgz", + "integrity": "sha512-di2nOSxPOu9kgNb4G3Y0uAOCX4mKmTCfzyJxTQBKaj0F9ymugUO9Epu4n5Vez7x96KpfIZPA1AzbNVArYmVX2g==", "license": "MIT", "dependencies": { - "@sentry/browser-utils": "11.10.0", - "@sentry/core": "11.10.0" + "@sentry/browser-utils": "11.11.0", + "@sentry/core": "11.11.0" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" } }, "node_modules/@sentry/replay-canvas": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-11.10.0.tgz", - "integrity": "sha512-i0Fsjbl8ChzTMntLVhZvn72+p395tGNtL4iflsprnzPhTVSOGW9IYgUYfO87UcujUf5CmYn7ELBKUc5V0Ipfdg==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-11.11.0.tgz", + "integrity": "sha512-iVI8+Y+iC6/B6oEjDv63HzxzCsOIEiyb0MzyJiU45/IgQnriP1FUehqO6GitFtjEasl9//lWQL0RwPy97Eb7NQ==", "license": "MIT", "dependencies": { - "@sentry/core": "11.10.0", - "@sentry/replay": "11.10.0" + "@sentry/core": "11.11.0", + "@sentry/replay": "11.11.0" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" } }, "node_modules/@sentry/server-runtime-injection": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/server-runtime-injection/-/server-runtime-injection-11.10.0.tgz", - "integrity": "sha512-1nc+HNPldrPEov16sL5m2cOLVgNJ+FsR7QtaWGOOv8qYFJkSp95lyc2qzLnaG2L72UzSMOv10peGRioAKrrZQQ==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/server-runtime-injection/-/server-runtime-injection-11.11.0.tgz", + "integrity": "sha512-nHHWfOYi2sB7db9Hjr3i/H66e1/YK9WMlBx3MSI6p/Ha66BGHF3vea/+gILjPz21viQclg1xq37mj8k9KLkGJw==", "license": "MIT", "dependencies": { - "@sentry/core": "11.10.0", - "@sentry/server-utils": "11.10.0" + "@sentry/core": "11.11.0", + "@sentry/server-utils": "11.11.0" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" } }, "node_modules/@sentry/server-utils": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-11.10.0.tgz", - "integrity": "sha512-m/MVCvPzQJJZZCTFlVPCrTuoncT7SR80+QKg1K7ul25AsL0byABr5KsHCbXVpOYXLVz04Omk+pOOR5Cv2IAlrA==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-11.11.0.tgz", + "integrity": "sha512-t1lx9Zw+zhRre0ko+F3CsucG2BqB/7KOZmG1W+Wv/fSvYSnT06vg7WhIvmvH+df7BSFO+cFGBpkxwQWGq3zj+w==", "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/conventions": "^0.29.0", - "@sentry/core": "11.10.0" + "@sentry/conventions": "^0.30.0", + "@sentry/core": "11.11.0" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" } }, "node_modules/@sentry/vercel-edge": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/vercel-edge/-/vercel-edge-11.10.0.tgz", - "integrity": "sha512-JEtbdiYGjkBqJBEHyvzDEcsSdvJQuTxWnlitvPtBpquHRkk+DWqnJ3aSL6Dv8vMiw9nUpzBescYKLR7EJ/t4JA==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/vercel-edge/-/vercel-edge-11.11.0.tgz", + "integrity": "sha512-/EA3ilVCh7bPes6a2Qbs/M6C17kBQAeQy0yL10W/26hDKapAuO5PnjimbwslEpgxDyZFZUzGBQByp0StyDlVFQ==", "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/core": "11.10.0", - "@sentry/server-utils": "11.10.0" + "@sentry/core": "11.11.0", + "@sentry/server-utils": "11.11.0" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" @@ -7767,16 +7773,17 @@ } }, "node_modules/eslint-config-next/node_modules/typescript-eslint/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.4.tgz", + "integrity": "sha512-/UJ1dEoVQTjPNDh6SGlUo+ueLMmoHTas4q+8yaAR/5t6nLAIiBDJtoYUh20IYleix7ehiQkgczb84PE3uzHkxg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "@isaacs/nonrecursive-glob-match": "^1.0.3", + "brace-expansion": "^5.0.12" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -8265,9 +8272,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", - "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz", + "integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==", "dev": true, "funding": [ { @@ -8521,14 +8528,14 @@ } }, "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-14.0.3.tgz", + "integrity": "sha512-A1vjIwC3qpdCso45Iw127gnp7sPhqgY8lUQhDrArIs+GbmfaaVPFuoyy7WyLT9EPZCRZ7j7aLdwUhQmHWesEow==", "license": "BlueOak-1.0.0", "dependencies": { - "minimatch": "^10.2.2", + "minimatch": "^11.0.2", "minipass": "^7.1.3", - "path-scurry": "^2.0.2" + "path-scurry": "^3.0.0" }, "engines": { "node": "18 || 20 || >=22" @@ -8572,15 +8579,16 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.4.tgz", + "integrity": "sha512-/UJ1dEoVQTjPNDh6SGlUo+ueLMmoHTas4q+8yaAR/5t6nLAIiBDJtoYUh20IYleix7ehiQkgczb84PE3uzHkxg==", "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.8" + "@isaacs/nonrecursive-glob-match": "^1.0.3", + "brace-expansion": "^5.0.12" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -10938,9 +10946,9 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-3.0.0.tgz", + "integrity": "sha512-1pTbQqH1wIwMi/eleQ5leFTTCkzFsPcgXNjwOA8YGGW/W4/kHT3dssE7Z6NH+zuqNLOzOB0uaD61yl7cAcVhgg==", "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", @@ -10954,9 +10962,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -11584,15 +11592,16 @@ } }, "node_modules/readdir-glob/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.4.tgz", + "integrity": "sha512-/UJ1dEoVQTjPNDh6SGlUo+ueLMmoHTas4q+8yaAR/5t6nLAIiBDJtoYUh20IYleix7ehiQkgczb84PE3uzHkxg==", "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.8" + "@isaacs/nonrecursive-glob-match": "^1.0.3", + "brace-expansion": "^5.0.12" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -11743,13 +11752,13 @@ } }, "node_modules/rimraf": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", - "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.4.tgz", + "integrity": "sha512-Fk3reQDVPZUMbEm7Zwxz03BWQMW3V5buSk9aU8OckTrO8A1nQtbqvYsAantGNermwsIsXcuOBqcoJ3HuMELctw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "glob": "^13.0.3", + "glob": "^14.0.3", "package-json-from-dist": "^1.0.1" }, "bin": { diff --git a/package.json b/package.json index 8e20b65744..e2468a3526 100644 --- a/package.json +++ b/package.json @@ -387,7 +387,13 @@ "unzipper": "^0.12.5" }, "braces": "^3.0.3", - "argparse": "^2.0.1" + "argparse": "^2.0.1", + "@typescript-eslint/typescript-estree": { + "minimatch": "^11.0.4" + }, + "readdir-glob": { + "minimatch": "^11.0.4" + } }, "devDependencies": { "@axe-core/playwright": "^4.14.0", 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..0337ab3b9e 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) { @@ -120,22 +127,61 @@ function imageToVisualInput(image: BackfillImageRow): IndexUnitVisualImage { }; } +function committedGeneration(metadata: unknown) { + const value = maintenanceMetadata(metadata).index_generation_id; + return typeof value === "string" ? value : null; +} + +/** + * Candidates from each document's committed generation only. Retained images from an older + * generation can stay searchable with a stale version; backfillDocument would discard them, so + * filtering after a plain `.limit()` could fill every run with them and starve later candidates. + * Page past them until `limit` committed-generation candidates are found or the rows run out. + */ async function loadCandidateImages() { - const { data, error } = await supabase - .from("document_images") - .select("id,document_id,page_number,caption,image_type,source_kind,labels,metadata") - .eq("searchable", true) - .or( - [ - "metadata->>visual_intelligence_version.is.null", - `metadata->>visual_intelligence_version.neq.${visualIntelligenceVersion}`, - "metadata->>visual_backfill_status.eq.retry", - ].join(","), - ) - .order("created_at", { ascending: true }) - .limit(limit); - if (error) throw new Error(error.message); - return (data ?? []) as BackfillImageRow[]; + const candidates: BackfillImageRow[] = []; + const generations = new Map(); + const looked = new Set(); + // Keep pages below the API's response cap (1,000 rows by default) and advance by the rows + // actually returned, so a capped page can never end the scan early. + const pageSize = 100; + for (let offset = 0; candidates.length < limit;) { + const { data, error } = await supabase + .from("document_images") + .select("id,document_id,page_number,caption,image_type,source_kind,labels,metadata,index_generation_id") + .eq("searchable", true) + .or( + [ + "metadata->>visual_intelligence_version.is.null", + `metadata->>visual_intelligence_version.neq.${visualIntelligenceVersion}`, + "metadata->>visual_backfill_status.eq.retry", + ].join(","), + ) + .order("created_at", { ascending: true }) + .order("id", { ascending: true }) + .range(offset, offset + pageSize - 1); + if (error) throw new Error(error.message); + const page = (data ?? []) as BackfillImageRow[]; + const unknown = Array.from(new Set(page.map((image) => image.document_id))).filter((id) => !looked.has(id)); + if (unknown.length) { + const { data: documents, error: documentError } = await supabase + .from("documents") + .select("id,metadata") + .in("id", unknown); + if (documentError) throw new Error(documentError.message); + for (const id of unknown) looked.add(id); + for (const document of documents ?? []) generations.set(document.id, committedGeneration(document.metadata)); + } + for (const image of page) { + if (candidates.length >= limit) break; + // A document that no longer exists has no committed generation and yields no candidates. + if (generations.has(image.document_id) && generations.get(image.document_id) === image.index_generation_id) + candidates.push(image); + } + if (page.length === 0) break; + offset += page.length; + } + return candidates; } function statusForImage(image: BackfillImageRow) { @@ -223,35 +269,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 generation = committedGeneration(document.metadata); + // Recheck against the fresh snapshot: the committed generation can move after candidate selection. + 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 +303,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 +358,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/document-drafts.py b/scripts/document-drafts.py index 2c4eef1412..1cc1758a60 100644 --- a/scripts/document-drafts.py +++ b/scripts/document-drafts.py @@ -63,18 +63,24 @@ def check_root(path): def read_text(path): - data = path.read_bytes() + with path.open('rb') as stream: + data = stream.read(MAX_BYTES + 1) if len(data) > MAX_BYTES or b'\0' in data: raise ValueError('oversized or binary document') data.decode('utf-8') return data +def write_new(path, data): + fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + with os.fdopen(fd, 'wb') as stream: + stream.write(data) + + def write_json(path, value): metadata_path(path.parent, path.name) temporary = path.with_suffix('.tmp') - with temporary.open('x', encoding='utf-8') as stream: - json.dump(value, stream, indent=2) + write_new(temporary, json.dumps(value, indent=2).encode('utf-8')) os.replace(temporary, path) @@ -93,8 +99,21 @@ def disjoint(source, workspace): raise ValueError('workspace must be outside source checkout') +def check_workspace(workspace): + workspace = check_root(workspace) + info = workspace.stat() + if not stat.S_ISDIR(info.st_mode): + raise ValueError('workspace must be a directory') + # A setgid bit inherited from the parent grants no access; ignore it so a + # workspace created under a setgid directory still meets the 0700 contract. + if os.name == 'posix' and (info.st_uid != os.geteuid() or stat.S_IMODE(info.st_mode) & ~stat.S_ISGID != 0o700): + raise ValueError('workspace must be owned by the current user with mode 0700; preserve it and use a fresh private workspace') + return workspace + + @contextmanager def locked(workspace): + check_workspace(workspace) lock = workspace / '.draft-lock' fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) try: @@ -106,6 +125,7 @@ def locked(workspace): def load(workspace, expected_source=None): + check_workspace(workspace) manifest = json.loads(metadata_path(workspace, 'manifest.json').read_text(encoding='utf-8')) if manifest.get('version') != 1 or manifest['workspace'] != str(workspace): raise ValueError('workspace identity mismatch') @@ -144,7 +164,7 @@ def prepare(source, workspace, task, names): contents = [(name, read_text(safe_path(source, name))) for name in names] if sum(len(data) for _, data in contents) > MAX_BYTES: raise ValueError('total byte limit exceeded') - destination.mkdir(parents=True, exist_ok=True) + destination.mkdir(parents=True, exist_ok=True, mode=0o700) with locked(destination): if (destination / 'manifest.json').exists(): existing = load(destination, source) @@ -158,8 +178,7 @@ def prepare(source, workspace, task, names): for folder in ('base', 'draft'): target = safe_path(destination / folder, name) target.parent.mkdir(parents=True, exist_ok=True) - with target.open('xb') as stream: - stream.write(data) + write_new(target, data) entries.append({'path': name, 'original_sha256': digest(data)}) manifest = dict(version=1, source=str(source), workspace=str(destination), task=task, files=entries) write_json(destination / 'manifest.json', manifest) @@ -174,7 +193,7 @@ def merge_bytes(workspace, proposed, base, latest): with tempfile.TemporaryDirectory(dir=workspace) as temporary: paths = [Path(temporary) / n for n in ('draft', 'base', 'source')] for path, data in zip(paths, (proposed, base, latest)): - path.write_bytes(data) + write_new(path, data) result = subprocess.run(['git', 'merge-file', '-p', '-L', 'draft', '-L', 'base', '-L', 'latest', *map(str, paths)], capture_output=True, timeout=30) if result.returncode < 0 or result.returncode > 127: raise ValueError('git merge-file failed') @@ -245,8 +264,7 @@ def reconcile(workspace, manifest, resolved): for folder, data in [('candidate', merged), ('source-input', source), ('draft-input', draft)]: target = safe_path(generation / folder, name) target.parent.mkdir(parents=True, exist_ok=True) - with target.open('xb') as stream: - stream.write(data) + write_new(target, data) snapshots[folder] = target.relative_to(workspace).as_posix() output.append(dict(path=name, before_sha256=digest(source), draft_sha256=digest(draft), candidate=snapshots['candidate'], candidate_sha256=digest(merged), source_snapshot=snapshots['source-input'], draft_snapshot=snapshots['draft-input'], conflict=conflict, review_reason=review_reason)) except (OSError, ValueError, UnicodeError, subprocess.SubprocessError) as error: 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..8f42294edd 100644 --- a/scripts/lib/daily-health-summary.mjs +++ b/scripts/lib/daily-health-summary.mjs @@ -107,6 +107,8 @@ export const SUMMARY_SOURCES = [ issue: "open", onTrouble: "red", trouble: "checks on main are failing", + // Only pushes to main judge main; a pull-request run of ci.yml says nothing about main. + runFilter: { branch: "main", event: "push" }, // Amber, not red, when the only outstanding checks are the cross-browser legs or the advisory // visual baselines (readMainCiOutstanding). Those legs are continue-on-error on main and // block nothing. Before this, one flaky Safari tablet leg kept the summary red for days @@ -246,7 +248,15 @@ 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})` + : ""; + // An unsuccessful run may not have reached its issue write, so an older issue alone may not + // explain the failure: keep the run reachable alongside it. + const failedRun = latestRun !== null && latestRun.conclusion !== "success"; + const link = + failedRun && issueLink(issue) && runLink ? `${issueLink(issue)} · ${runLink}` : 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 +309,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"; @@ -384,7 +405,7 @@ export function renderDailyHealthSummary({ now, assessments, answerLines = {}, m return { overall, body: lines.join("\n") }; } -async function newestCompletedRun(github, owner, repo, workflow) { +async function newestCompletedRun(github, owner, repo, workflow, filter = {}) { try { const { data } = await github.rest.actions.listWorkflowRuns({ owner, @@ -392,6 +413,7 @@ async function newestCompletedRun(github, owner, repo, workflow) { workflow_id: workflow, status: "completed", per_page: 1, + ...filter, }); return data.workflow_runs?.[0] ?? null; } catch { @@ -516,7 +538,7 @@ export async function collectAssessments({ github, owner, repo, now }) { for (const source of SUMMARY_SOURCES) { try { const [latestRun, { issue, activity }] = await Promise.all([ - newestCompletedRun(github, owner, repo, source.workflow), + newestCompletedRun(github, owner, repo, source.workflow, source.runFilter), source.issue === "recent" ? mostRecentlyAlertedIssue(github, owner, repo, source.label, nowMs - source.recentHours * HOUR) : newestIssue(github, owner, repo, source.label).then((found) => ({ 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 972377fa5f..671dd533ff 100644 --- a/scripts/lib/tenancy-scan.mjs +++ b/scripts/lib/tenancy-scan.mjs @@ -2022,9 +2022,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/run-playwright.mjs b/scripts/run-playwright.mjs index 0b51ff54a8..6150c49ded 100644 --- a/scripts/run-playwright.mjs +++ b/scripts/run-playwright.mjs @@ -9,6 +9,7 @@ import ts from "typescript"; import { childProcessExitCode, childProcessFailureSummary } from "./child-process-result.mjs"; import { assertPlaywrightBrowsersReady } from "./playwright-browser-preflight.mjs"; import { removePathSync } from "./retryable-fs.mjs"; +import { nextBuildEnvironment } from "./run-next-build.mjs"; import { offlineTestEnvironment } from "./test-environment.mjs"; import { acquireHeavyRunLock } from "./test-run-lock.mjs"; import { @@ -424,7 +425,8 @@ try { )}\n`, "utf8", ); - const offlineEnv = offlineTestEnvironment(lock.environment, { + // Next's TypeScript worker inherits NODE_OPTIONS, not the parent's CLI heap flag. + const offlineEnv = offlineTestEnvironment(nextBuildEnvironment(lock.environment), { PORT: String(port), PLAYWRIGHT_BASE_URL: baseUrl, NEXT_DIST_DIR: relativeDistDir, diff --git a/scripts/test-document-drafts.py b/scripts/test-document-drafts.py index 06a3f4c77b..15781e645f 100644 --- a/scripts/test-document-drafts.py +++ b/scripts/test-document-drafts.py @@ -1,7 +1,10 @@ import base64 import importlib.util +import io import json +import os from pathlib import Path +import stat import tempfile import unittest import subprocess @@ -415,6 +418,101 @@ def test_resolution_preserves_legitimate_equals_heading(self): self.assertFalse(self.reconcile(['note.md'])['conflict']) self.assertIn(b'=======', base64.b64decode(d.export(self.workspace, self.manifest)[0]['after_base64'])) + @unittest.skipUnless(os.name == 'posix', 'POSIX permission contract') + def test_copies_and_reconciliation_keep_private_document_audience_under_022(self): + parent = self.root / 'traversable-parent' + parent.mkdir(mode=0o755) + parent.chmod(0o755) + source = parent / 'source' + source.mkdir(mode=0o700) + original = source / 'note.md' + original.write_bytes(b'private synthetic document\n') + original.chmod(0o600) + workspace = parent / 'copies' + previous_umask = os.umask(0o022) + try: + manifest = d.prepare(source, workspace, 'private-copy', ['note.md']) + d.reconcile(workspace, manifest, []) + finally: + os.umask(previous_umask) + self.assertEqual(0o700, stat.S_IMODE(workspace.stat().st_mode)) + for artifact in workspace.rglob('*'): + if artifact.is_file(): + self.assertEqual(0o600, stat.S_IMODE(artifact.stat().st_mode), str(artifact)) + self.assertEqual(0o600, stat.S_IMODE(original.stat().st_mode)) + self.assertEqual(b'private synthetic document\n', original.read_bytes()) + + @unittest.skipUnless(os.name == 'posix', 'POSIX permission contract') + def test_existing_public_workspace_refuses_without_chmod_or_new_files(self): + workspace = self.root / 'public-workspace' + workspace.mkdir(mode=0o755) + workspace.chmod(0o755) + with self.assertRaises(ValueError): + d.prepare(self.source, workspace, 'existing-public', ['note.md']) + self.assertEqual(0o755, stat.S_IMODE(workspace.stat().st_mode)) + self.assertEqual([], list(workspace.iterdir())) + + @unittest.skipUnless(os.name == 'posix', 'POSIX setgid contract') + def test_new_workspace_under_setgid_parent_is_accepted_and_resumable(self): + parent = self.root / 'setgid-parent' + parent.mkdir(mode=0o755) + parent.chmod(0o2755) + if not parent.stat().st_mode & stat.S_ISGID: + self.skipTest('filesystem does not keep the setgid bit') + workspace = parent / 'copies' + manifest = d.prepare(self.source, workspace, 'setgid', ['note.md']) + self.assertEqual(0o700, stat.S_IMODE(workspace.stat().st_mode) & ~stat.S_ISGID) + self.assertEqual(manifest, d.prepare(self.source, workspace, 'setgid', ['note.md'])) + + @unittest.skipUnless(os.name == 'posix', 'POSIX ownership contract') + def test_existing_private_workspace_of_another_owner_refuses_before_writes(self): + workspace = self.root / 'other-owner' + workspace.mkdir(mode=0o700) + workspace.chmod(0o700) + actual_owner = workspace.stat().st_uid + with patch.object(d.os, 'geteuid', return_value=actual_owner + 1): + with self.assertRaises(ValueError): + d.prepare(self.source, workspace, 'other-owner', ['note.md']) + self.assertEqual(actual_owner, workspace.stat().st_uid) + self.assertEqual(0o700, stat.S_IMODE(workspace.stat().st_mode)) + self.assertEqual([], list(workspace.iterdir())) + + def test_oversized_text_refuses_after_only_limit_plus_one_input_bytes(self): + reads = [] + + class Input(io.BytesIO): + def read(self, size=-1): + reads.append(size) + return super().read(size) + + class FileProbe: + def read_bytes(self): + reads.append(-1) + return b'x' * 17 + + def open(self, mode): + if mode != 'rb': + raise AssertionError('document must be opened read-only') + return Input(b'x' * 17) + + with patch.object(d, 'MAX_BYTES', 16): + with self.assertRaises(ValueError): + d.read_text(FileProbe()) + self.assertEqual([17], reads) + + def test_bounded_read_preserves_utf8_binary_and_exact_limit_contracts(self): + path = self.root / 'bounded.txt' + with patch.object(d, 'MAX_BYTES', 4): + path.write_bytes(b'\xc3\xa9\xc3\xa9') + self.assertEqual(b'\xc3\xa9\xc3\xa9', d.read_text(path)) + for value in (b'abcde', b'ab\0c'): + path.write_bytes(value) + with self.assertRaises(ValueError): + d.read_text(path) + path.write_bytes(b'abc\xff') + with self.assertRaises(UnicodeDecodeError): + d.read_text(path) + if __name__ == '__main__': unittest.main() diff --git a/scripts/verify-storage-restore.mjs b/scripts/verify-storage-restore.mjs new file mode 100644 index 0000000000..3789f074cf --- /dev/null +++ b/scripts/verify-storage-restore.mjs @@ -0,0 +1,211 @@ +#!/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"); + // Hash the exact file bytes and refuse invalid UTF-8: lenient decoding maps invalid bytes to + // U+FFFD, so a changed manifest could otherwise decode, parse and report the recorded digest. + const bytes = await readFile(options["--manifest"]); + let encoded; + try { + encoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new Error("Storage manifest is not valid UTF-8"); + } + return { + ...(await verifyStorageRestore(options["--root"], JSON.parse(encoded))), + manifestSha256: createHash("sha256").update(bytes).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/(search-app)/cme/log/page.tsx b/src/app/(search-app)/cme/log/page.tsx index 51eb8e5bc8..f47bc6c612 100644 --- a/src/app/(search-app)/cme/log/page.tsx +++ b/src/app/(search-app)/cme/log/page.tsx @@ -3,6 +3,7 @@ import type { Metadata } from "next"; import { CmeLogPage, type CmeLogAttention } from "@/components/cme/cme-log-page"; import { CmeStateNotice } from "@/components/cme/cme-state-notice"; import { cpdYearOf, perthCalendarDate } from "@/lib/cme/cpd-year"; +import { withoutCmeEntryVersions } from "@/lib/cme/entry-version"; import { loadCmePageData } from "@/lib/cme/load-cme-page-data"; import { cmeCategories, type CmeCategory, type CmeRequirementSet } from "@/lib/cme/types"; @@ -51,8 +52,9 @@ export default async function CmeLogRoute({ return ( }) { 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 466a404858..a825630539 100644 --- a/src/components/clinical-dashboard/clinical-ask-answer-surface.tsx +++ b/src/components/clinical-dashboard/clinical-ask-answer-surface.tsx @@ -12,7 +12,7 @@ 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 }> = [ @@ -52,7 +52,7 @@ export function clinicalAskExportText( claimExportText(response.lead, response.evidence), ...response.sections.flatMap((section) => [ "", - section.title, + clinicalAskSectionTitle(response.mode, section.id), ...section.claims.map((claim) => claimExportText(claim, response.evidence)), ]), "", @@ -236,7 +236,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/teaching/teaching-this-week.tsx b/src/components/teaching/teaching-this-week.tsx index 79ea785976..43f4e2f2cc 100644 --- a/src/components/teaching/teaching-this-week.tsx +++ b/src/components/teaching/teaching-this-week.tsx @@ -1,5 +1,7 @@ "use client"; +import { OnCallEntrySessionBoundary } from "@/components/on-call/on-call-entry-session-boundary"; + import dynamic from "next/dynamic"; import { useSearchParams } from "next/navigation"; import { CalendarClock, CalendarDays, ChevronLeft, ChevronRight, Network } from "lucide-react"; @@ -150,7 +152,7 @@ const NO_CLASHES: ReadonlyMap = new Map(); * Remounts on sign-in, sign-out, account switch and demo change, like TeachingAccountPage, so the last * week kept for offline reading never shows one account's sessions to another. */ -export function TeachingThisWeek({ +function TeachingThisWeekSession({ demoMode: serverDemoMode, week = null, view = null, @@ -831,3 +833,11 @@ function DayStrip({ days, today }: { days: ReturnType; today: ); } + +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 36f11e4841..d9d8c9f56e 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 { addDays, type CalendarEvent } from "@/lib/calendar/calendar-event"; @@ -215,19 +215,31 @@ async function fetchOwnerBookingEvents( export async function calendarFeedEvents(supabase: AdminClient, ownerId: string, now: Date): Promise { const year = cpdYearOf(now); + const today = perthCalendarDate(now); const calendarPreferences = fetchOwnerCalendarPreferences(supabase, ownerId); - const [thisYear, nextYear, routines, teaching, teachingSessions, preferences, 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), - calendarPreferences, - fetchOwnerRosterSettingsForFeed(supabase, ownerId), - fetchRotationFeedEvents(supabase, ownerId, now), - calendarPreferences.then(({ zone }) => fetchOwnerBookingEvents(supabase, ownerId, now, zone)), - ]); + const [ + previousYear, + thisYear, + nextYear, + routines, + teaching, + teachingSessions, + preferences, + 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), + calendarPreferences, + fetchOwnerRosterSettingsForFeed(supabase, ownerId), + fetchRotationFeedEvents(supabase, ownerId, now), + calendarPreferences.then(({ zone }) => fetchOwnerBookingEvents(supabase, ownerId, now, zone)), + ]); let rosterShifts: CalendarEvent[] = []; if (rosterSettings.calendarShifts) { const own = await fetchOwnerShifts(supabase, ownerId, now); @@ -236,6 +248,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..5b2303a109 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"; @@ -116,13 +120,18 @@ export function governClinicalAskDraft( evidence: readonly ClinicalAskEvidence[], ): ClinicalAskResponse { const evidenceById = new Map(evidence.map((item) => [item.id, item])); - if (draft.mode !== profile.id || draft.sections.map(({ id }) => id).join("|") !== profile.sectionOrder.join("|")) { + // Compare each section id on its own: a joined string would accept an id such as "a|b". + const sectionOrderMatches = + draft.sections.length === profile.sectionOrder.length && + draft.sections.every(({ id }, index) => id === profile.sectionOrder[index]); + if (draft.mode !== profile.id || !sectionOrderMatches) { return evidenceGap(profile, evidence, [...draft.missingInformation, "The answer structure was invalid."]); } 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..df9554d901 --- /dev/null +++ b/src/lib/cme/entry-version.ts @@ -0,0 +1,36 @@ +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)), + }; +} + +/** + * Drop the edit version from entries a page only displays. Each version repeats the entry's + * content, so a display-only list (the Log, across all years) would send it twice. Edit and + * amendment flows read the entry on their own route and keep the version. + */ +export function withoutCmeEntryVersions(entries: readonly T[]): T[]; +export function withoutCmeEntryVersions(entries: readonly T[] | undefined): T[] | undefined; +export function withoutCmeEntryVersions(entries: readonly T[] | undefined): T[] | undefined { + return entries?.map((entry) => { + if (!("version" in entry)) return entry; + const rest = { ...entry }; + delete (rest as { version?: CmeEntryVersion }).version; + return rest; + }); +} 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..3515695a5e 100644 --- a/src/lib/deep-memory.ts +++ b/src/lib/deep-memory.ts @@ -1,3 +1,9 @@ +import { + assertLegacyMaintenanceSnapshot, + commitMaintenanceDeepMemory, + maintenanceGenerationId, + type MaintenanceDocument, +} from "@/lib/ingestion-maintenance"; import { parseIngestionFence, runIngestionPersistence, @@ -109,79 +115,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 +158,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 +170,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 +183,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 +216,16 @@ 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."); + // The deep-memory commit requires a committed generation. Refuse before any provider call or + // staged write, rather than failing at commit and leaving staged rows behind. + if (maintenanceGenerationId(maintenanceDocument) === null) { + throw new Error(`Document ${args.document.id} has no committed index generation.`); + } - 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 +299,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 +379,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 +387,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 ed0a0cdd1a..977a2b726c 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, @@ -592,6 +597,7 @@ export async function upsertDocumentEnrichment(args: { supabase: SupabaseClient; document: Pick & { metadata?: ClinicalDocument["metadata"]; + updated_at?: string; }; chunks: EnrichmentChunk[]; images?: EnrichmentImage[]; @@ -617,10 +623,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); @@ -670,12 +678,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, @@ -685,32 +693,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, @@ -719,13 +703,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; @@ -1067,6 +1049,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 de8b8b7d49..90e31eddcd 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/http.ts b/src/lib/http.ts index 45832dd7d0..969c8acd86 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -53,6 +53,7 @@ function logSafeError(error: unknown, status: number) { ...safeErrorLogDetails(error), ...(details?.code ? { code: details.code } : {}), ...(details?.requestId ? { requestId: details.requestId } : {}), + ...(details?.causeName ? { causeName: details.causeName } : {}), ...(details?.sqlState ? { sqlState: details.sqlState } : {}), }); } diff --git a/src/lib/ingestion-maintenance.ts b/src/lib/ingestion-maintenance.ts new file mode 100644 index 0000000000..a09c154ea5 --- /dev/null +++ b/src/lib/ingestion-maintenance.ts @@ -0,0 +1,126 @@ +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) : {}; +} + +// Mirrors documents.index_generation_id (20260714110000): only a well-formed UUID string is a +// committed generation; anything else in the metadata maps to null, exactly as the generated column does. +const committedGenerationPattern = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + +/** The document's committed generation as the database column sees it, or null. */ +export function maintenanceGenerationId(document: Pick): string | null { + const generation = maintenanceMetadata(document.metadata).index_generation_id; + return typeof generation === "string" && committedGenerationPattern.test(generation) ? generation : null; +} + +/** 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 = maintenanceGenerationId(document); + query = generation === null ? query.is("index_generation_id", null) : query.eq("index_generation_id", generation); + 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 { 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: maintenanceGenerationId(args.document), + 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 { 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: maintenanceGenerationId(args.document), + }); + 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..f7ae405f8e 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,7 +404,9 @@ export function useStoredOnCallEntries(): OnCallEntriesState { useEffect(() => { const epochAtStart = peekOnCallEntrySessionEpoch(); + const revisionAtStart = cacheRevision; let cancelled = false; + let refetching = false; const controller = new AbortController(); (async () => { @@ -402,17 +420,25 @@ 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)), - ); + 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) return; + if (cacheRevision !== revisionAtStart) { + // A save landed while this list was loading. Never write this older list + // over it; read the list again instead, so earlier entries are not left + // missing from a cache that holds only the new one. Loading stays true. + refetching = true; + setAttempt((current) => current + 1); + return; + } setIsOffline(false); setLoadError(null); setSignedOut(parsedResponse.data.signedOut); @@ -423,7 +449,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. @@ -434,7 +460,7 @@ export function useStoredOnCallEntries(): OnCallEntriesState { setIsOffline(true); setLoadError(typeof navigator !== "undefined" && navigator.onLine === false ? "offline" : "failed"); } finally { - if (!cancelled && peekOnCallEntrySessionEpoch() === epochAtStart) setLoading(false); + if (!cancelled && !refetching && peekOnCallEntrySessionEpoch() === epochAtStart) setLoading(false); } })(); 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 26705341fc..3c4afa4108 100644 --- a/src/lib/openai.ts +++ b/src/lib/openai.ts @@ -152,6 +152,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..b64d6f4222 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,17 @@ function usedProxyClaims(request: Request): boolean { return !request.headers.has("authorization") && extractProxyAuthenticatedUser(request) !== null; } +async function getFreshRequestUser(request: Request, supabase: AdminClient): Promise { + // Same precedence as `resolveOptionalAuthentication`: a current session (plain or + // encoded) wins over a leftover legacy `sb-access-token`, and an invalid current + // session never falls back to the legacy token. + const token = extractBearerAccessToken(request) ?? extractCurrentCookieSessionAccessToken(request); + if (token) return getUserFromAccessToken(supabase, token); + if (hasCurrentSessionCookie(request)) return getUserFromRequestCookies(request); + const legacyToken = extractLegacyCookieSessionAccessToken(request); + return legacyToken ? getUserFromAccessToken(supabase, legacyToken) : getUserFromRequestCookies(request); +} + export async function requireAuthenticatedUser( request: Request, supabase: AdminClient, @@ -318,15 +332,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 49408f7192..2b5df3cd17 100644 --- a/src/lib/supabase/database.types.ts +++ b/src/lib/supabase/database.types.ts @@ -936,6 +936,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[]; @@ -964,6 +965,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[]; @@ -992,6 +994,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[]; @@ -5234,6 +5237,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 }; @@ -5246,6 +5268,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; @@ -6348,10 +6398,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..49f6444190 100644 --- a/src/lib/work-roles/people.ts +++ b/src/lib/work-roles/people.ts @@ -567,10 +567,38 @@ 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 (error) { + // Keep a safe category for the API log (denied, not ready, unavailable...), never the message. + const causeName = + error instanceof PublicApiError && error.details?.code + ? error.details.code + : error instanceof Error + ? error.name + : "unknown"; + 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", + causeName, + }, + ); + } +} + export async function applyPeopleChange( client: Client, context: WorkRoleContext, @@ -585,16 +613,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 +647,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 +657,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 fb4f8f39ba..e6e20c29c1 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-11-06T22:37:39.020Z", + "sealed_at": "2026-11-06T22:44:58.234Z", "versions": { "20260527000000_bulk_ingestion.sql": "813e8e314064a6dcbaaa55e2765a8ce901d5783b304b4a7af7bb7417a07daf7c", "20260527001000_documents_policy_cleanup.sql": "559c26f3172bf75e545a41adef75538b2bf648ea7d53c8eb3d3ea7c244c65a48", @@ -358,7 +358,16 @@ "20261103000300_managed_full_pdf_generation.sql": "1ba22b26e4b2af79fca31613a17cdeca06d1161d9f07b67d9d5d75e9ce885d07", "20261104000000_managed_full_pdf_summary_projection.sql": "568613b9b33116bfe2ce6eff84e2eddb30d05a29b240c46305e51394c8d1e9c7", "20261105000000_managed_reviewed_rendered_pdf.sql": "db78a109c166c2bce3f60b7cfacd945800a9556898b17035d46481ade347b0f5", + "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", + "20261106164443_audit_fix_maintenance_snapshot_fence_aliases.sql": "42cd773c3db4fedf78590dba182ac17997b56cc5de789a68eef624308e460d37", "20261106184724_defer_readmission_ranking_authority.sql": "8d5f352c2f9a9212090e03fd69beff0133f32a4ce75d2dc0274fbe41a5efa2ee", - "20261106223559_full_pdf_packet_parent_lookups.sql": "b1adaefb172f6e857f5dfaae7c432e7f004555f6395b545f7c85837b5bba4eb7" + "20261106223559_full_pdf_packet_parent_lookups.sql": "b1adaefb172f6e857f5dfaae7c432e7f004555f6395b545f7c85837b5bba4eb7", + "20261106224403_audit_overlap_skip_malformed_booking_times.sql": "60cef458f23e955d86b232019fdafb9a3d2936aa853a046ffd6e15be536948bd" } } diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index 5173611452..a5a7e1f312 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,9 +1,9 @@ { - "generated_at": "2026-11-05T02:55:57.204Z", + "generated_at": "2026-11-06T18:52:27.699Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83", - "schema_sha256": "f36f7fac5757e214d4cbc2dd86388ff80e25ec1ccb30533070c0e98b61d75ac2", - "replay_seconds": 22, + "schema_sha256": "d08fa71922845310bb79ff35875f77aaa2f62580d7b607ca4154459148bb5045", + "replay_seconds": 16, "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", @@ -19328,7 +19348,15 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "75e6365d05e5254ed1ab7105bc7a5077", + "def_hash": "f92eb7f19e851854565ce471c3e5da6c", + "signature": "public.cme_amend_closed_entry_v2(uuid,uuid,jsonb,text)" + }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "5441f74e90ac73cd54eacc3c74aeae3b", "signature": "public.cme_amend_closed_entry(uuid,uuid,jsonb,text)" }, { @@ -19352,7 +19380,15 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "bc871df2f59aba77fb9a0979da2f9d86", + "def_hash": "d44a8f5f7ec24127a53868d05209eaca", + "signature": "public.cme_close_year_v2(uuid,uuid,jsonb,text)" + }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "66fb1271bb3f11bb7692292bde3f2a00", "signature": "public.cme_close_year(uuid,uuid,jsonb,text)" }, { @@ -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()" }, { @@ -19447,7 +19491,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "997a20a5a425edd6bb7c3c25e0e8eff2", + "def_hash": "9dc21188c933cf36fc4fdbdda16185e2", "signature": "public.cme_guard_requirement()" }, { @@ -19479,7 +19523,15 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "cb723dadd2d13699cf4443a140368edb", + "def_hash": "e276a589f81e815c7e2c2bb5aa2945c6", + "signature": "public.cme_save_entry_v2(uuid,uuid,uuid,jsonb,boolean,uuid)" + }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "a8ff4e9a90a7d404be21f218d29ea6ee", "signature": "public.cme_save_entry(uuid,uuid,uuid,jsonb,boolean,uuid)" }, { @@ -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", @@ -19706,7 +19765,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "cf81c12782a5f977f4cddee4bd918b84", + "def_hash": "217ddfda304309af01e8a8a0dd6d9fd6", "signature": "public.delete_document_if_idle(uuid,uuid,text,text)" }, { @@ -19763,6 +19822,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", @@ -20679,6 +20746,14 @@ "def_hash": "31258714cad37bf42e122f60c88e9242", "signature": "public.repair_strict_enrichment_gate_batch(integer)" }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "d361332965588f8092efddf362b179c0", + "signature": "public.replace_document_maintenance_artifacts(uuid,uuid,timestamp with time zone,uuid,text,jsonb)" + }, { "acl": [ "postgres=X/postgres", @@ -20750,14 +20825,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", @@ -21141,7 +21208,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "d4604873b639ee7f2592c1013f9c5563", + "def_hash": "d8a2df2d9e40953ae14a5bf741e77cc7", "signature": "public.schema_drift_snapshot()" }, { @@ -21528,6 +21595,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", @@ -21568,6 +21643,22 @@ "def_hash": "2c394b4d9809e679a339230af729a710", "signature": "public.teaching_feed_events(uuid,date,date)" }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "25cee7ef0a740501e534f65cba49b6f5", + "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", @@ -21917,6 +22008,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", @@ -27051,6 +27158,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", diff --git a/supabase/migrations/20261107000100_audit_private_evidence_lifecycle_and_drift.sql b/supabase/migrations/20261107000100_audit_private_evidence_lifecycle_and_drift.sql new file mode 100644 index 0000000000..63df3f9617 --- /dev/null +++ b/supabase/migrations/20261107000100_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/20261107000200_audit_cpd_close_coherence.sql b/supabase/migrations/20261107000200_audit_cpd_close_coherence.sql new file mode 100644 index 0000000000..12a96b302d --- /dev/null +++ b/supabase/migrations/20261107000200_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/20261107000300_audit_hospital_lifecycle.sql b/supabase/migrations/20261107000300_audit_hospital_lifecycle.sql new file mode 100644 index 0000000000..f083faff12 --- /dev/null +++ b/supabase/migrations/20261107000300_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/20261107000400_audit_atomic_document_maintenance.sql b/supabase/migrations/20261107000400_audit_atomic_document_maintenance.sql new file mode 100644 index 0000000000..997f3cf38b --- /dev/null +++ b/supabase/migrations/20261107000400_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/20261107000500_audit_teaching_link_and_booking_lifecycle.sql b/supabase/migrations/20261107000500_audit_teaching_link_and_booking_lifecycle.sql new file mode 100644 index 0000000000..e59c820b1e --- /dev/null +++ b/supabase/migrations/20261107000500_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/20261107000600_audit_cpd_entry_concurrency.sql b/supabase/migrations/20261107000600_audit_cpd_entry_concurrency.sql new file mode 100644 index 0000000000..6e80b3227d --- /dev/null +++ b/supabase/migrations/20261107000600_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/20261107000700_audit_retire_unfenced_document_reset.sql b/supabase/migrations/20261107000700_audit_retire_unfenced_document_reset.sql new file mode 100644 index 0000000000..0d00a2ccb6 --- /dev/null +++ b/supabase/migrations/20261107000700_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/migrations/20261107000800_audit_fix_maintenance_snapshot_fence_aliases.sql b/supabase/migrations/20261107000800_audit_fix_maintenance_snapshot_fence_aliases.sql new file mode 100644 index 0000000000..f74446706e --- /dev/null +++ b/supabase/migrations/20261107000800_audit_fix_maintenance_snapshot_fence_aliases.sql @@ -0,0 +1,189 @@ +-- Correct the snapshot fences in replace_document_maintenance_artifacts (20261107000400). +-- Its `unnest(...) id ... x.id=id` checks resolved the bare `id` to the inner table's own +-- column, so the label, summary-source and visual image generation fences were always +-- satisfied. Alias the unnest as s(id) and compare against s.id. Body otherwise unchanged. +-- Draft only. Applying/merging this migration requires the owner's own database approval. +set local lock_timeout = '5s'; +set local statement_timeout = '60s'; + +create or replace 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) as s(id) where not exists(select 1 from public.document_labels l where l.id=s.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) as s(id) where not exists(select 1 from public.document_chunks c + where c.id=s.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) as s(id) where not exists(select 1 from public.document_images i + where i.id=s.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) as s(id) where not exists(select 1 from public.document_images i + where i.id=s.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; diff --git a/supabase/migrations/20261107000900_audit_overlap_skip_malformed_booking_times.sql b/supabase/migrations/20261107000900_audit_overlap_skip_malformed_booking_times.sql new file mode 100644 index 0000000000..d1b33f57a1 --- /dev/null +++ b/supabase/migrations/20261107000900_audit_overlap_skip_malformed_booking_times.sql @@ -0,0 +1,49 @@ +-- teaching_find_supervisor_overlap_v1 (20261107000500) cast each existing booking's +-- startsAt/endsAt string straight to timestamptz, so one malformed stored timestamp aborted +-- the whole overlap check with booking_database_error. The app check it replaced skipped a +-- booking whose times Date.parse could not read. A string that is not a valid timestamptz +-- now reads as null, so that booking is never an overlap. Valid strings compare as before, +-- and non-string values keep their created_at/updated_at fallbacks. Body otherwise unchanged. +-- Draft only. Applying/merging this migration requires the owner's own database approval. +set local lock_timeout = '5s'; +set local statement_timeout = '60s'; + +create or replace 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. + -- pg_input_is_valid keeps one malformed stored time from aborting the check. + 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 + case when pg_input_is_valid(r.ratings->>'startsAt', 'timestamptz') + then (r.ratings->>'startsAt')::timestamptz else null end + 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 + case when pg_input_is_valid(r.ratings->>'endsAt', 'timestamptz') + then (r.ratings->>'endsAt')::timestamptz else null end + 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/schema.sql b/supabase/schema.sql index 199d671f57..1174e0b41c 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -41875,3 +41875,1864 @@ end $$; -- Patched validators retain their postgres ownership and existing ACLs. commit; + +-- supabase/migrations/20261107000100_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/20261107000200_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/20261107000300_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/20261107000400_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/20261107000500_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/20261107000600_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/20261107000700_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; + + +-- supabase/migrations/20261107000800_audit_fix_maintenance_snapshot_fence_aliases.sql +-- Correct the snapshot fences in replace_document_maintenance_artifacts (20261107000400). +-- Its `unnest(...) id ... x.id=id` checks resolved the bare `id` to the inner table's own +-- column, so the label, summary-source and visual image generation fences were always +-- satisfied. Alias the unnest as s(id) and compare against s.id. Body otherwise unchanged. +-- Draft only. Applying/merging this migration requires the owner's own database approval. +set local lock_timeout = '5s'; +set local statement_timeout = '60s'; + +create or replace 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) as s(id) where not exists(select 1 from public.document_labels l where l.id=s.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) as s(id) where not exists(select 1 from public.document_chunks c + where c.id=s.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) as s(id) where not exists(select 1 from public.document_images i + where i.id=s.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) as s(id) where not exists(select 1 from public.document_images i + where i.id=s.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; + + +-- supabase/migrations/20261107000900_audit_overlap_skip_malformed_booking_times.sql +-- teaching_find_supervisor_overlap_v1 (20261107000500) cast each existing booking's +-- startsAt/endsAt string straight to timestamptz, so one malformed stored timestamp aborted +-- the whole overlap check with booking_database_error. The app check it replaced skipped a +-- booking whose times Date.parse could not read. A string that is not a valid timestamptz +-- now reads as null, so that booking is never an overlap. Valid strings compare as before, +-- and non-string values keep their created_at/updated_at fallbacks. Body otherwise unchanged. +-- Draft only. Applying/merging this migration requires the owner's own database approval. +set local lock_timeout = '5s'; +set local statement_timeout = '60s'; + +create or replace 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. + -- pg_input_is_valid keeps one malformed stored time from aborting the check. + 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 + case when pg_input_is_valid(r.ratings->>'startsAt', 'timestamptz') + then (r.ratings->>'startsAt')::timestamptz else null end + 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 + case when pg_input_is_valid(r.ratings->>'endsAt', 'timestamptz') + then (r.ratings->>'endsAt')::timestamptz else null end + 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/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..0d6d8ce163 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,65 @@ 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("rechecks a save on the current encoded session, never a leftover legacy token", async () => { + const client = authClient({ data: { user: { id: "synthetic-user", app_metadata: {} } }, error: null }); + const encodedSession = `base64-${Buffer.from(JSON.stringify({ access_token: "synthetic-current" })).toString("base64")}`; + const request = new Request("http://localhost/api/account/favourites", { + method: "POST", + headers: { + [PROXY_AUTH_USER_HEADER]: proxyHeader(), + cookie: `sb-testprojectref123-auth-token=${encodedSession}; sb-access-token=synthetic-expired-legacy`, + }, + }); + await resolveOptionalAuthentication(request, client as never); + await requireAuthenticatedUser(request, client as never).catch(() => undefined); + expect(client.auth.getUser).not.toHaveBeenCalledWith("synthetic-expired-legacy"); + }); + + 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 9b6caead72..c70ca8519d 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,8 +49,8 @@ import { hashCalendarFeedToken, parseCalendarFeedToken, } from "@/lib/calendar/feed-token"; -import { toIcs } from "@/lib/calendar/ics"; import { calendarFeedEvents } from "@/lib/calendar/feed-repository"; +import { toIcs } from "@/lib/calendar/ics"; import { AuthenticationError } from "@/lib/supabase/auth"; const ownerId = "11111111-1111-4111-8111-111111111111"; @@ -90,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..20d1b31aa8 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,49 @@ 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("rejects section ids that only match the required order when joined", () => { + const profile = clinicalAskModeProfile("specifiers"); + expect(profile.sectionOrder.length).toBeGreaterThan(1); + const value = draft("specifiers"); + const [first, second, ...rest] = value.sections; + value.sections = [{ ...first, id: `${first.id}|${second.id}` }, ...rest]; + const response = governClinicalAskDraft(profile, value, evidence); + expect(response.state).toBe("evidence_gap"); + }); + + 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 + + {JSON.stringify({ draft: session.draft, @@ -71,6 +77,56 @@ describe("useClinicalAskRunner", () => { streamClinicalAsk.mockReset(); }); + it.each(["Stop", "Clear", "account change", "unmount"])( + "skips deferred stream initialization after %s", + async (action) => { + const view = render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Run" })); + if (action === "unmount") view.unmount(); + else if (action === "account change") + view.rerender( + + + , + ); + else fireEvent.click(screen.getByRole("button", { name: action })); + await act(async () => { + await vi.dynamicImportSettled(); + }); + expect(streamClinicalAsk).not.toHaveBeenCalled(); + }, + ); + + it("starts only the latest request when replacement happens before lazy initialization", async () => { + const realClient = await vi.importActual( + "@/lib/clinical-ask/client-stream", + ); + streamClinicalAsk.mockImplementation(realClient.streamClinicalAsk); + const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 401 })); + vi.stubGlobal("fetch", fetch); + const view = render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Run" })); + view.rerender( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Run" })); + await act(async () => { + await vi.dynamicImportSettled(); + }); + expect(fetch).toHaveBeenCalledOnce(); + expect(JSON.parse(fetch.mock.calls[0]?.[1].body)).toMatchObject({ question: "Question B" }); + }); + it("keeps run B active when the aborted run A settles", async () => { const pending: PendingRun[] = []; streamClinicalAsk.mockImplementation( diff --git a/tests/clinical-ask-stream-contract.test.ts b/tests/clinical-ask-stream-contract.test.ts index 15ede86f10..3338a38664 100644 --- a/tests/clinical-ask-stream-contract.test.ts +++ b/tests/clinical-ask-stream-contract.test.ts @@ -149,6 +149,36 @@ describe("Clinical Ask SSE contract", () => { inputTransport: "typed" as const, }; + it("does not dispatch a request whose caller already cancelled", async () => { + const controller = new AbortController(); + controller.abort(); + const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 401 })); + vi.stubGlobal("fetch", fetch); + const onEvent = vi.fn(); + const result = await streamClinicalAsk(streamRequest, controller.signal, onEvent); + expect(fetch).not.toHaveBeenCalled(); + expect(onEvent).not.toHaveBeenCalled(); + expect(result.response).toMatchObject({ state: "failed", code: "aborted", retryable: false }); + }); + + it("forwards cancellation after dispatch to the transport", async () => { + const controller = new AbortController(); + let transportSignal: AbortSignal | undefined; + vi.stubGlobal( + "fetch", + vi.fn((_url: string, options: RequestInit) => { + transportSignal = options.signal as AbortSignal; + return new Promise((_resolve, reject) => { + transportSignal!.addEventListener("abort", () => reject(transportSignal!.reason), { once: true }); + }); + }), + ); + const pending = streamClinicalAsk(streamRequest, controller.signal, vi.fn()); + controller.abort(); + expect(transportSignal?.aborted).toBe(true); + expect((await pending).response).toMatchObject({ state: "failed", code: "aborted" }); + }); + it("surfaces a 429 as rate_limited with the server retry window instead of internal_error", async () => { vi.stubGlobal( "fetch", diff --git a/tests/clinical-ask-web-budget.test.ts b/tests/clinical-ask-web-budget.test.ts new file mode 100644 index 0000000000..fb7ae3fd87 --- /dev/null +++ b/tests/clinical-ask-web-budget.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const sdk = vi.hoisted(() => ({ create: vi.fn() })); +vi.mock("openai", () => ({ + default: class MockOpenAI { + responses = { create: sdk.create }; + }, +})); +vi.mock("@/lib/env", () => ({ + env: { + OPENAI_API_KEY: "synthetic-unused-key", + OPENAI_ANSWER_MODEL: "synthetic-model", + OPENAI_MAX_OUTPUT_TOKENS: 16000, + OPENAI_REQUEST_TIMEOUT_MS: 20000, + OPENAI_MAX_RETRIES: 0, + }, + requireOpenAIEnv: vi.fn(), +})); +vi.mock("@/lib/answer-pilot-budget", () => ({ + currentAnswerPilotBudget: () => null, + isAnswerPilotBudgetActive: () => false, +})); +vi.mock("@/lib/observability/agent-monitoring", () => ({ + instrumentOpenAIClientForAgentMonitoring: (client: T) => client, +})); +vi.mock("@/lib/logger", () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } })); + +import { createClinicalAskWebSearchResponse } from "@/lib/openai"; + +beforeEach(() => { + sdk.create.mockReset(); + sdk.create.mockResolvedValue({ status: "completed", output: [] }); + vi.stubGlobal( + "fetch", + vi.fn(() => { + throw new Error("Unexpected network in offline budget check"); + }), + ); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("Clinical Ask external-search request budget", () => { + it("uses the configured output ceiling and one provider-managed tool call", async () => { + await createClinicalAskWebSearchResponse({ + input: [], + allowedDomains: ["health.wa.gov.au"], + signal: new AbortController().signal, + timeoutMs: 20000, + }); + expect(sdk.create.mock.calls[0]?.[0]).toMatchObject({ max_output_tokens: 16000, max_tool_calls: 1 }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("preserves the authority allow-list, cancellation, timeout, no-store and zero retries", async () => { + const signal = new AbortController().signal; + await createClinicalAskWebSearchResponse({ + input: [], + allowedDomains: ["health.wa.gov.au"], + signal, + timeoutMs: 20000, + }); + expect(sdk.create).toHaveBeenCalledWith( + expect.objectContaining({ + store: false, + tools: [expect.objectContaining({ filters: { allowed_domains: ["health.wa.gov.au"] } })], + }), + { signal, timeout: 20000, maxRetries: 0 }, + ); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/clinical-ask-workspace.dom.test.tsx b/tests/clinical-ask-workspace.dom.test.tsx index 44538b082d..5763615780 100644 --- a/tests/clinical-ask-workspace.dom.test.tsx +++ b/tests/clinical-ask-workspace.dom.test.tsx @@ -213,6 +213,47 @@ describe("ClinicalAskWorkspace", () => { HTMLElement.prototype.scrollIntoView = vi.fn(); }); + it("renders and copies canonical section labels even for a legacy model-authored heading", () => { + const title = "The service will accept the referral"; + const claim = { + id: "c", + text: "The source indicates that clinician review may consider the example.", + evidenceIds: ["e1"], + }; + const response: Extract = { + state: "answered", + mode: "services", + lead: claim, + sections: [{ id: "potential_matches", title, claims: [claim] }], + evidence: [ + { + id: "e1", + tier: "catalogue", + title: "Synthetic evidence", + publisher: "Synthetic publisher", + jurisdiction: null, + href: "/synthetic", + extract: "", + reviewState: "reviewed", + publishedAt: null, + updatedAt: null, + retrievedAt: null, + }, + ], + conflicts: [], + missingInformation: [], + followUps: [], + handoffs: [], + }; + render(); + expect(screen.getByRole("heading", { level: 3, name: "Potential matches" })).toBeInTheDocument(); + expect(document.body.textContent).not.toContain(title); + const copied = clinicalAskExportText(response); + expect(copied).toContain("\nPotential matches\n"); + expect(copied).not.toContain(title); + expect(copied).toContain(claim.text); + }); + // D26-10: a stalled Clinical Ask spun with no end. it("offers Keep waiting or Stop once an answer has stalled", () => { vi.useFakeTimers(); diff --git a/tests/cme-api-contract.test.ts b/tests/cme-api-contract.test.ts index f741ea2273..c533d06513 100644 --- a/tests/cme-api-contract.test.ts +++ b/tests/cme-api-contract.test.ts @@ -133,7 +133,7 @@ describe("the CME API", () => { expect(repository).toMatch(/owner_id: ownerId/); expect(repository).toMatch(/\.from\("cme_allocations"\)[\s\S]*?\.insert\(/); expect(detail).toMatch(/saveCmeEntry\(/); - expect(repository).toMatch(/rpc\("cme_save_entry",\s*\{\s*p_owner_id: ownerId/); + expect(repository).toMatch(/rpc\("cme_save_entry_v2",\s*\{\s*p_owner_id: ownerId/); expect(repository).toMatch(/rpc\("cme_confirm_year",\s*\{\s*p_owner_id:\s*ownerId/); }); }); @@ -166,7 +166,7 @@ describe("CME entry [id] route", () => { expect(detail).toMatch(/clearCmeEntryTranscribed\(/); expect(detail).toMatch(/Object\.keys\(rawBody as object\)\.length === 1/); expect(detail).toMatch(/saveCmeEntry\(/); - expect(repository).toMatch(/rpc\("cme_save_entry"/); + expect(repository).toMatch(/rpc\("cme_save_entry_v2"/); }); }); diff --git a/tests/cme-archive-export.dom.test.tsx b/tests/cme-archive-export.dom.test.tsx index 56b0747a37..6dac0da2aa 100644 --- a/tests/cme-archive-export.dom.test.tsx +++ b/tests/cme-archive-export.dom.test.tsx @@ -8,6 +8,7 @@ import { CmeNewEntryRoute } from "@/components/cme/cme-new-entry-route"; import { CmeAnnualSummary } from "@/components/cme/cme-annual-summary"; import { createAustralianRanzcpPreset } from "@/lib/cme/presets"; import type { CmeEntry } from "@/lib/cme/types"; +import { cmeEntryVersion } from "@/lib/cme/entry-version"; const nav = vi.hoisted(() => ({ push: vi.fn(), refresh: vi.fn() })); vi.mock("next/navigation", () => ({ useRouter: () => nav, usePathname: () => "/cme/log/entry" })); vi.mock("@/components/cme/cme-evidence-panel", () => ({ @@ -40,6 +41,98 @@ afterEach(() => { nav.refresh.mockReset(); }); describe("Archive, annual record and learning journeys", () => { + it.each([false, true])( + "keeps the draft and its original version together after refresh (closed year: %s)", + async (amend) => { + const user = userEvent.setup(); + const yearSet = amend ? { ...set, closedAt: "2026-12-20T00:00:00Z" } : set; + const original = { ...entry, reflection: "Original saved reflection" }; + const loaded = { ...original, version: cmeEntryVersion(original) }; + const latest = { ...entry, reflection: "Later actor's saved reflection" }; + const refreshed = { ...latest, version: cmeEntryVersion(latest) }; + let stored = latest; + const fetcher = vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) => { + const body = JSON.parse(String(init?.body)); + if (JSON.stringify(body.expectedVersion) !== JSON.stringify(refreshed.version)) { + return Response.json( + { message: "This activity changed in another tab. Reload it before saving your changes." }, + { status: 409 }, + ); + } + stored = { ...stored, title: body.title, reflection: body.reflection }; + return Response.json({ entry: stored }); + }); + const { rerender } = render(); + const title = screen.getByRole("textbox", { name: /What was it/ }); + await user.clear(title); + await user.type(title, "My unsaved correction"); + if (amend) await user.type(screen.getByLabelText(/Reason for this amendment/), "Synthetic correction reason"); + rerender(); + expect(title).toHaveValue("My unsaved correction"); + expect(screen.getByText(/Your draft is still here/)).toBeInTheDocument(); + if (amend) expect(screen.getByLabelText(/Reason for this amendment/)).toHaveValue("Synthetic correction reason"); + await user.click(screen.getByRole("button", { name: amend ? "Record amendment" : "Save changes" })); + const body = JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body)); + expect(body.expectedVersion).toEqual(loaded.version); + expect(body.reflection).toBe(original.reflection); + expect(await screen.findByText(/This activity changed in another tab/)).toBeInTheDocument(); + expect(title).toHaveValue("My unsaved correction"); + expect(stored.reflection).toBe(latest.reflection); + expect(nav.refresh).not.toHaveBeenCalled(); + }, + ); + + it("starts a fresh draft and version for another activity and after reopening edit from view", async () => { + const user = userEvent.setup(); + const loaded = { ...entry, version: cmeEntryVersion(entry) }; + const other = { ...entry, id: "22222222-2222-4222-8222-222222222222", title: "Another activity" }; + const otherLoaded = { ...other, version: cmeEntryVersion(other) }; + const latest = { ...other, title: "Latest saved activity", reflection: "Latest saved reflection" }; + const latestLoaded = { ...latest, version: cmeEntryVersion(latest) }; + const fetcher = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(Response.json({ message: "Synthetic refusal keeps the form open" }, { status: 409 })); + const { rerender } = render(); + await user.type(screen.getByRole("textbox", { name: /What was it/ }), " unsaved first activity"); + rerender(); + expect(screen.getByRole("textbox", { name: /What was it/ })).toHaveValue(other.title); + await user.type(screen.getByRole("textbox", { name: /What was it/ }), " unsaved second activity"); + rerender(); + rerender(); + expect(screen.getByRole("textbox", { name: /What was it/ })).toHaveValue(latest.title); + await user.click(screen.getByRole("button", { name: "Save changes" })); + expect(fetcher.mock.calls[0]?.[0]).toBe(`/api/cme/entries/${other.id}`); + expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toMatchObject({ + reflection: latest.reflection, + expectedVersion: latestLoaded.version, + }); + }); + + it("submits the loaded version and preserves the draft when another editor has saved first", async () => { + const user = userEvent.setup(); + const loaded = { ...entry, version: cmeEntryVersion(entry) }; + const fetcher = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue( + Response.json( + { message: "This activity changed in another tab. Reload it before saving your changes." }, + { status: 409 }, + ), + ); + render(); + const title = screen.getByRole("textbox", { name: /What was it/ }); + await user.clear(title); + await user.type(title, "My unsaved correction"); + await user.click(screen.getByRole("button", { name: "Save changes" })); + expect(await screen.findByText(/This activity changed in another tab/)).toBeInTheDocument(); + expect(title).toHaveValue("My unsaved correction"); + expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toMatchObject({ + title: "My unsaved correction", + expectedVersion: loaded.version, + }); + expect(nav.refresh).not.toHaveBeenCalled(); + }); + it("retains active controls after failed archive, then restores only after confirmed success", async () => { const user = userEvent.setup(); const fetcher = vi diff --git a/tests/cme-core-workflows.test.ts b/tests/cme-core-workflows.test.ts index 3443fc92ba..b9f45089c7 100644 --- a/tests/cme-core-workflows.test.ts +++ b/tests/cme-core-workflows.test.ts @@ -18,6 +18,7 @@ import { saveCmeRoutine, } from "@/lib/cme/repository"; import type { CmeEntry } from "@/lib/cme/types"; +import { cmeEntryVersion } from "@/lib/cme/entry-version"; const entry: CmeEntry = { id: "11111111-1111-4111-8111-111111111111", @@ -136,15 +137,18 @@ describe("CME transaction boundaries and owner scoping", () => { error: null, }), }; - await saveCmeEntry(client as never, "owner", "year", entry); + await saveCmeEntry(client as never, "owner", "year", { ...entry, version: cmeEntryVersion(entry) }); const args = client.rpc.mock.calls[0][1]; expect(args.p_create).toBe(false); expect(args.p_entry).not.toHaveProperty("transcribed"); expect(args.p_entry.formalPeerReviewHours).toBe(1.5); + expect(args.p_entry.expectedVersion).toEqual(cmeEntryVersion(entry)); }); it("safely reports closed-year rejection from the transaction", async () => { const client = { rpc: vi.fn().mockResolvedValue({ data: null, error: { message: "cme_year_closed" } }) }; - await expect(saveCmeEntry(client as never, "owner", "year", entry)).rejects.toMatchObject({ status: 409 }); + await expect( + saveCmeEntry(client as never, "owner", "year", { ...entry, version: cmeEntryVersion(entry) }), + ).rejects.toMatchObject({ status: 409, details: { code: "cme_year_closed" } }); }); it("looks up entry IDs with an owner predicate and returns the same missing result", async () => { const chain = { diff --git a/tests/cme-entry-concurrency.test.ts b/tests/cme-entry-concurrency.test.ts new file mode 100644 index 0000000000..7d3bf2d023 --- /dev/null +++ b/tests/cme-entry-concurrency.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from "vitest"; +import { amendClosedCmeEntry, rowToCmeEntry, saveCmeEntry } from "@/lib/cme/repository"; + +const owner = "11111111-1111-4111-8111-111111111111"; +const year = "22222222-2222-4222-8222-222222222222"; +const initial = { + id: "33333333-3333-4333-8333-333333333333", + activity_date: "2026-05-01", + title: "Synthetic learning activity", + reflection: "Original reflection", + cost_cents: null, + routine_id: null, + document_id: null, + source_url: null, + buckets: ["Ethical practice"], + formal_peer_review_hours: 0, + transcribed_at: null as string | null, + archived_at: null as string | null, + cme_allocations: [{ category: "educational" as const, hours: 2 }], +}; + +function storedVersion(row: typeof initial) { + return { + id: row.id, + date: row.activity_date, + title: row.title, + reflection: row.reflection, + costCents: row.cost_cents, + routineId: row.routine_id, + documentId: row.document_id, + sourceUrl: row.source_url, + buckets: row.buckets, + formalPeerReviewHours: row.formal_peer_review_hours, + transcribed: row.transcribed_at !== null, + archived: row.archived_at !== null, + allocations: [...row.cme_allocations].sort((a, b) => a.category.localeCompare(b.category)), + }; +} + +// This fake distinguishes the vulnerable unversioned boundary from the new DB contract. +// Real PostgreSQL lock schedules are verified separately by the controller. +function actors() { + let row = structuredClone(initial); + const rpc = vi.fn(async (name: string, args: Record) => { + const body = args.p_entry as Record; + if (args.p_owner_id !== owner) return { data: null, error: { message: "cme_entry_not_found" } }; + if (args.p_year_id !== undefined && args.p_year_id !== year) + return { data: null, error: { message: "cme_year_not_confirmed" } }; + if (name.endsWith("_v2") && JSON.stringify(body.expectedVersion) !== JSON.stringify(storedVersion(row))) + return { data: null, error: { message: "cme_entry_conflict" } }; + row = { ...row, title: body.title as string, reflection: body.reflection as string }; + return { data: structuredClone(row), error: null }; + }); + return { + client: { rpc }, + read: () => rowToCmeEntry(structuredClone(row), row.cme_allocations), + mutate: (change: Partial) => { + row = { ...row, ...change }; + }, + row: () => row, + }; +} + +describe("CPD full-record edits use the version the editor loaded", () => { + it("preserves the first actor's reflection when a second actor submits an older full record", async () => { + const db = actors(); + const first = db.read(); + const second = db.read(); + await saveCmeEntry(db.client as never, owner, year, { ...first, reflection: "First actor's saved reflection" }); + await expect( + saveCmeEntry(db.client as never, owner, year, { ...second, title: "Second actor's title" }), + ).rejects.toMatchObject({ status: 409, details: { code: "cme_entry_conflict" } }); + expect(db.row()).toMatchObject({ title: initial.title, reflection: "First actor's saved reflection" }); + }); + + it.each([ + { transcribed_at: "2026-05-02T00:00:00Z" }, + { archived_at: "2026-05-02T00:00:00Z" }, + { cme_allocations: [{ category: "educational" as const, hours: 3 }] }, + { activity_date: "2027-05-01" }, + ])("rejects a full editor after an alternate mutation: %j", async (change) => { + const db = actors(); + const editor = db.read(); + db.mutate(change); + await expect( + saveCmeEntry(db.client as never, owner, year, { ...editor, title: "Stale replacement" }), + ).rejects.toMatchObject({ status: 409 }); + expect(db.row().title).toBe(initial.title); + }); + + it("rejects a stale amendment before adding another full replacement", async () => { + const db = actors(); + const editor = db.read(); + db.mutate({ reflection: "A later recorded amendment" }); + await expect( + amendClosedCmeEntry(db.client as never, owner, editor, "Synthetic correction reason"), + ).rejects.toMatchObject({ status: 409, details: { code: "cme_entry_conflict" } }); + expect(db.row().reflection).toBe("A later recorded amendment"); + }); + + it("requires a loaded version for edits and amendments before issuing writes", async () => { + const db = actors(); + const withoutVersion = { ...db.read(), version: undefined }; + await expect(saveCmeEntry(db.client as never, owner, year, withoutVersion)).rejects.toMatchObject({ status: 409 }); + await expect( + amendClosedCmeEntry(db.client as never, owner, withoutVersion, "Synthetic correction"), + ).rejects.toMatchObject({ status: 409 }); + expect(db.client.rpc).not.toHaveBeenCalled(); + }); + + it.each(["save", "amend"])("fails closed when the %s migration is unavailable", async (operation) => { + const db = actors(); + db.client.rpc.mockResolvedValue({ data: null, error: { message: "RPC missing", code: "PGRST202" } } as never); + const promise = + operation === "save" + ? saveCmeEntry(db.client as never, owner, year, db.read()) + : amendClosedCmeEntry(db.client as never, owner, db.read(), "Synthetic correction"); + await expect(promise).rejects.toMatchObject({ status: 503 }); + expect(db.client.rpc).toHaveBeenCalledTimes(1); + expect(db.client.rpc.mock.calls[0]?.[0]).toBe( + operation === "save" ? "cme_save_entry_v2" : "cme_amend_closed_entry_v2", + ); + }); + + it("binds the current owner and target year independently of the content version", async () => { + const db = actors(); + await expect(saveCmeEntry(db.client as never, "another-owner", year, db.read())).rejects.toMatchObject({ + status: 404, + }); + await expect(saveCmeEntry(db.client as never, owner, "another-year", db.read())).rejects.toMatchObject({ + status: 400, + }); + expect(db.row()).toEqual(initial); + }); +}); diff --git a/tests/cme-log-copy-sheet.dom.test.tsx b/tests/cme-log-copy-sheet.dom.test.tsx index e1416edcce..cf00e238b9 100644 --- a/tests/cme-log-copy-sheet.dom.test.tsx +++ b/tests/cme-log-copy-sheet.dom.test.tsx @@ -5,6 +5,7 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cpdHomeFields } from "@/components/cme/cme-log-copy-fields"; +import { CmeLogCopySheet } from "@/components/cme/cme-log-copy-sheet"; import { CmeLogPage } from "@/components/cme/cme-log-page"; import { formatEntryForCpdHome } from "@/lib/cme/clipboard"; import { DEMO_CME_YEAR } from "@/lib/cme/demo-year"; @@ -101,6 +102,46 @@ describe("cpdHomeFields", () => { }); describe("copy to your CPD home, one at a time", () => { + it("keeps another year's activity clipboard-only until that year's closed state is known", async () => { + const user = userEvent.setup(); + stubClipboard(); + const older = { ...ENTRIES[0]!, date: "2025-09-16" }; + const onMark = vi.fn(); + render( + {}} + candidates={[older]} + lookup={() => older} + set={SET} + demoMode={false} + onMark={onMark} + onUndo={async () => {}} + lastMarkedId={null} + />, + ); + const sheet = screen.getByTestId("cme-log-copy-sheet"); + expect(sheet).toHaveTextContent("Open this activity's year to change its copied status."); + expect(within(sheet).queryByTestId("cme-log-copy-mark")).toBeNull(); + await user.click(within(sheet).getByTestId("cme-log-copy-all")); + expect(writeText).toHaveBeenCalled(); + expect(onMark).not.toHaveBeenCalled(); + }); + + it("copies closed-year text without offering an unsupported copied-state update", async () => { + const fetchMock = okFetch(); + const { user, sheet } = await openSheet({ set: { ...SET, closedAt: "2027-01-02T00:00:00Z" } }); + expect(sheet).toHaveTextContent( + "This year is closed. Copying is available; its recorded copied status stays unchanged.", + ); + expect(within(sheet).queryByTestId("cme-log-copy-mark")).toBeNull(); + await user.click(within(sheet).getByTestId("cme-log-copy-all")); + expect(writeText).toHaveBeenCalledWith(formatEntryForCpdHome(ENTRIES[0]!, SET)); + await user.click(within(sheet).getByRole("button", { name: "Next activity" })); + expect(within(sheet).getByText("2 of 2")).toBeInTheDocument(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("names the sheet, counts the queue and shows each field with its own Copy, never the cost", async () => { const { sheet } = await openSheet(); expect(within(sheet).getByText("Copy to your CPD home")).toBeInTheDocument(); diff --git a/tests/cme-page-data.test.ts b/tests/cme-page-data.test.ts index c4f2e8a33c..2a60daeb7b 100644 --- a/tests/cme-page-data.test.ts +++ b/tests/cme-page-data.test.ts @@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({ server: vi.fn(), admin: vi.fn(), year: vi.fn(), + years: vi.fn(), entries: vi.fn(), entry: vi.fn(), routines: vi.fn(), @@ -29,6 +30,7 @@ vi.mock("@/lib/cme/plan-goals-repository", () => ({ })); vi.mock("@/lib/cme/repository", () => ({ fetchOwnerCmeYear: mocks.year, + fetchOwnerCmeYears: mocks.years, fetchOwnerCmeEntries: mocks.entries, fetchOwnerCmeEntry: mocks.entry, fetchOwnerCmeRoutines: mocks.routines, @@ -44,6 +46,7 @@ beforeEach(() => { auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: "owner" } }, error: null }) }, }); mocks.year.mockResolvedValue(null); + mocks.years.mockResolvedValue([]); mocks.entries.mockResolvedValue([]); mocks.routines.mockResolvedValue([]); mocks.entry.mockResolvedValue(null); @@ -261,3 +264,59 @@ describe("local demo carry preview", () => { expect(cmeDemoCarryPreview(["confirm"])).toBeNull(); }); }); + +describe("cross-year CPD record reads", () => { + it("bounds concurrent years while preserving every entry across the supported calendar", async () => { + const years = Array.from({ length: 101 }, (_, index) => ({ id: `year-${2000 + index}`, year: 2000 + index })); + mocks.years.mockResolvedValue(years); + mocks.year.mockResolvedValue({ id: "year-2026", ...createAustralianRanzcpPreset(2026, "2026-01-02") }); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let active = 0; + let maximum = 0; + mocks.entries.mockImplementation(async (_admin, _owner, id) => { + if (id !== "year-2026") { + active += 1; + maximum = Math.max(maximum, active); + try { + await gate; + } finally { + active -= 1; + } + } + return [ + { id: `entry-${id}`, date: `${id.slice(5)}-03-01`, title: "Synthetic activity", allocations: [], buckets: [] }, + ]; + }); + const loading = loadCmePageData(2026, { allYears: true }); + try { + await vi.waitFor(() => expect(mocks.entries.mock.calls.length).toBeGreaterThanOrEqual(5)); + expect(maximum).toBeLessThanOrEqual(4); + } finally { + release(); + const data = await loading; + expect(data.allYearsFailed).toBe(false); + expect(data.allEntries).toHaveLength(101); + expect(new Set(data.allEntries?.map((entry) => entry.id)).size).toBe(101); + expect(mocks.entries).toHaveBeenCalledTimes(101); + expect(mocks.entries.mock.calls.every((call) => call[1] === "owner")).toBe(true); + } + }); + + it("does not return a partial cross-year record when one year fails", async () => { + mocks.year.mockResolvedValue({ id: "year-2026", ...createAustralianRanzcpPreset(2026, "2026-01-02") }); + mocks.years.mockResolvedValue([ + { id: "year-2026", year: 2026 }, + { id: "year-2025", year: 2025 }, + ]); + mocks.entries.mockImplementation(async (_admin, _owner, id) => { + if (id === "year-2025") throw new Error("synthetic missing year"); + return []; + }); + const data = await loadCmePageData(2026, { allYears: true }); + expect(data.allYearsFailed).toBe(true); + expect(data.allEntries).toBeUndefined(); + }); +}); diff --git a/tests/cme-repository.test.ts b/tests/cme-repository.test.ts index 066d9e1315..46c6c242be 100644 --- a/tests/cme-repository.test.ts +++ b/tests/cme-repository.test.ts @@ -14,6 +14,7 @@ import { rowToCmeEntry, } from "@/lib/cme/repository"; import type { CmeEntry } from "@/lib/cme/types"; +import { cmeEntryVersion, withoutCmeEntryVersions } from "@/lib/cme/entry-version"; describe("row mapping", () => { it("round-trips an entry with several allocations", () => { @@ -40,7 +41,14 @@ describe("row mapping", () => { { ...row, id: entry.id }, entry.allocations.map((a) => ({ category: a.category, hours: a.hours })), ), - ).toEqual(entry); + ).toEqual({ ...entry, version: cmeEntryVersion(entry) }); + }); + + it("drops edit versions from display-only lists and keeps every other field", () => { + const entry = { ...baseEntry(), evidenceCount: 2 }; + const versioned = { ...entry, version: cmeEntryVersion(entry) }; + expect(withoutCmeEntryVersions([versioned, entry])).toEqual([entry, entry]); + expect(withoutCmeEntryVersions(undefined)).toBeUndefined(); }); it("carries a cost through as whole cents, never a float", () => { @@ -75,7 +83,7 @@ describe("row mapping", () => { { ...row, id: entry.id }, entry.allocations.map((a) => ({ category: a.category, hours: a.hours })), ); - expect(result).toEqual(entry); + expect(result).toEqual({ ...entry, version: cmeEntryVersion(entry) }); }); }); @@ -403,7 +411,7 @@ describe("insertCmeEntry", () => { const client = { rpc: vi.fn().mockResolvedValue(response), from: vi.fn() }; const result = await insertCmeEntry(client as never, "owner-1", "year-1", ENTRY, "request-1"); expect(client.rpc).toHaveBeenCalledWith( - "cme_save_entry", + "cme_save_entry_v2", expect.objectContaining({ p_owner_id: "owner-1", p_year_id: "year-1", diff --git a/tests/cme-year-close.dom.test.tsx b/tests/cme-year-close.dom.test.tsx index 13e9bbb7dc..ff3b025768 100644 --- a/tests/cme-year-close.dom.test.tsx +++ b/tests/cme-year-close.dom.test.tsx @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CmeAnnualSummary } from "@/components/cme/cme-annual-summary"; import { CmeEntryRouteClient } from "@/components/cme/cme-entry-route-client"; +import { cmeEntryVersion } from "@/lib/cme/entry-version"; import { CmeYearEndActions } from "@/components/cme/cme-year-close-panel"; import { createAustralianRanzcpPreset } from "@/lib/cme/presets"; import type { CmeEntry, CmeYearClose } from "@/lib/cme/types"; @@ -208,7 +209,8 @@ describe("amending an activity in a closed year", () => { it("requires a reason and sends it with the complete record", async () => { const user = userEvent.setup(); const fetcher = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ entry }))); - render(); + const loadedEntry = { ...entry, version: cmeEntryVersion(entry) }; + render(); expect(screen.getByRole("heading", { name: "Amend activity" })).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Record amendment" })); expect(fetcher).not.toHaveBeenCalled(); @@ -219,6 +221,7 @@ describe("amending an activity in a closed year", () => { expect(fetcher).toHaveBeenCalledTimes(1); const body = JSON.parse(String(fetcher.mock.calls[0]![1]?.body)); expect(body).toMatchObject({ amendmentReason: "Certificate shows 4 hours", title: entry.title, date: entry.date }); + expect(body.expectedVersion).toEqual(loadedEntry.version); expect(fetcher.mock.calls[0]![1]).toMatchObject({ method: "PATCH" }); }); diff --git a/tests/cme-year-close.test.ts b/tests/cme-year-close.test.ts index 03498755d1..c2f4a4a7c1 100644 --- a/tests/cme-year-close.test.ts +++ b/tests/cme-year-close.test.ts @@ -28,6 +28,7 @@ import { DEMO_CME_YEAR } from "@/lib/cme/demo-year"; import { amendClosedCmeEntry, closeCmeYear, cmeRepositoryError, fetchOwnerCmeYearClose } from "@/lib/cme/repository"; import { cmeEntryAmendSchema, cmeYearCloseSchema } from "@/lib/cme/schemas"; import type { CmeEntry } from "@/lib/cme/types"; +import { cmeEntryVersion } from "@/lib/cme/entry-version"; import { buildCmeCloseEvaluation, canCloseCmeYear, @@ -94,6 +95,60 @@ describe("the evaluation recorded at closing", () => { expect.objectContaining({ requirementId: set.requirements[0]!.id, label: set.requirements[0]!.label }), ); }); + it("binds every evaluation input even when hours and entry count are unchanged", () => { + const set = { ...DEMO_CME_YEAR, year: 2025 }; + const original = buildCmeCloseEvaluation(set, [entry]); + const changes = [ + buildCmeCloseEvaluation(set, [{ ...entry, allocations: [{ category: "reviewing", hours: 2 }] }]), + buildCmeCloseEvaluation(set, [{ ...entry, formalPeerReviewHours: 1 }]), + buildCmeCloseEvaluation(set, [{ ...entry, buckets: ["Changed practice domain"] }]), + buildCmeCloseEvaluation({ ...set, totalHours: set.totalHours + 1 }, [entry]), + buildCmeCloseEvaluation( + { + ...set, + requirements: set.requirements.map((requirement, index) => + index === 0 ? { ...requirement, completedOn: "2025-03-02" } : requirement, + ), + }, + [entry], + ), + buildCmeCloseEvaluation( + { + ...set, + requirements: set.requirements.map((requirement, index) => + index === 0 ? { ...requirement, spec: { shape: "task" as const } } : requirement, + ), + }, + [entry], + ), + ]; + expect(original).toHaveProperty("inputs"); + for (const changed of changes) { + expect(changed.totalHours).toBe(original.totalHours); + expect(changed.entryCount).toBe(original.entryCount); + expect(changed.inputs).not.toEqual(original.inputs); + } + }); + + it("canonicalizes row ordering without treating archived entries as evaluation inputs", () => { + const set = { ...DEMO_CME_YEAR, year: 2025 }; + const first = { + ...entry, + allocations: [ + { category: "reviewing" as const, hours: 1 }, + { category: "educational" as const, hours: 1 }, + ], + }; + const second = { ...entry, id: "44444444-4444-4444-8444-444444444444" }; + expect(buildCmeCloseEvaluation(set, [first, second]).inputs).toEqual( + buildCmeCloseEvaluation({ ...set, requirements: [...set.requirements].reverse() }, [ + second, + { ...first, allocations: [...first.allocations].reverse() }, + { ...entry, id: "archived", archivedAt: "2025-04-01T00:00:00Z" }, + ]).inputs, + ); + }); + it("maps stored snapshot and amendment rows back into the record shown on the summary", () => { const close = rowsToCmeYearClose( { @@ -166,9 +221,9 @@ describe("year-close request schemas", () => { describe("year-close repository calls", () => { it("closes through one owner-scoped transaction and maps a stale evaluation to 409", async () => { const client = { rpc: vi.fn().mockResolvedValue({ data: {}, error: null }) }; - const evaluation = { totalHours: 2, entryCount: 1, requirements: [] }; + const evaluation = buildCmeCloseEvaluation({ ...DEMO_CME_YEAR, year: 2025 }, [entry]); await closeCmeYear(client as never, OWNER, YEAR_ID, evaluation, "Leave"); - expect(client.rpc).toHaveBeenCalledWith("cme_close_year", { + expect(client.rpc).toHaveBeenCalledWith("cme_close_year_v2", { p_owner_id: OWNER, p_year_id: YEAR_ID, p_evaluation: evaluation, @@ -179,10 +234,26 @@ describe("year-close repository calls", () => { status: 409, }); }); + it("fails before writing when the complete-input RPC is unavailable, without a legacy fallback", async () => { + const rpc = vi + .fn() + .mockResolvedValue({ data: null, error: { code: "PGRST202", message: "cme_close_year_v2 was not found" } }); + await expect( + closeCmeYear( + { rpc } as never, + OWNER, + YEAR_ID, + buildCmeCloseEvaluation({ ...DEMO_CME_YEAR, year: 2025 }, [entry]), + null, + ), + ).rejects.toMatchObject({ status: 503 }); + expect(rpc).toHaveBeenCalledTimes(1); + expect(rpc.mock.calls[0]?.[0]).toBe("cme_close_year_v2"); + }); it("refuses to close without an owner", async () => { const client = { rpc: vi.fn() }; await expect( - closeCmeYear(client as never, "", YEAR_ID, { totalHours: 0, entryCount: 0, requirements: [] }, null), + closeCmeYear(client as never, "", YEAR_ID, buildCmeCloseEvaluation(DEMO_CME_YEAR, []), null), ).rejects.toThrow(/owner/i); expect(client.rpc).not.toHaveBeenCalled(); }); @@ -201,11 +272,11 @@ describe("year-close repository calls", () => { const amended = await amendClosedCmeEntry( client as never, OWNER, - { ...entry, allocations: [{ category: "educational", hours: 4 }] }, + { ...entry, version: cmeEntryVersion(entry), allocations: [{ category: "educational", hours: 4 }] }, "Certificate shows 4 hours", ); expect(client.rpc).toHaveBeenCalledWith( - "cme_amend_closed_entry", + "cme_amend_closed_entry_v2", expect.objectContaining({ p_owner_id: OWNER, p_entry_id: ENTRY_ID, p_reason: "Certificate shows 4 hours" }), ); expect(amended.allocations).toEqual([{ category: "educational", hours: 4 }]); @@ -353,7 +424,7 @@ describe("POST /api/cme/year/close", () => { const response = await closeYear(closeRequest({ year: 2025, shortfallNote: " Leave " })); expect(response.status).toBe(200); expect(mocks.rpc).toHaveBeenCalledWith( - "cme_close_year", + "cme_close_year_v2", expect.objectContaining({ p_owner_id: OWNER, p_year_id: YEAR_ID, @@ -407,14 +478,46 @@ describe("PATCH /api/cme/entries/[id] with an amendment", () => { ...entry, allocations: [{ category: "educational", hours: 4 }], amendmentReason: "Certificate shows 4 hours", + expectedVersion: cmeEntryVersion(entry), }), context, ); expect(response.status).toBe(200); expect(mocks.rpc).toHaveBeenCalledTimes(1); - expect(mocks.rpc.mock.calls[0]![0]).toBe("cme_amend_closed_entry"); + expect(mocks.rpc.mock.calls[0]![0]).toBe("cme_amend_closed_entry_v2"); + expect(mocks.rpc.mock.calls[0]![1].p_entry.expectedVersion).toEqual(cmeEntryVersion(entry)); expect(mocks.rpc.mock.calls[0]![1]).toMatchObject({ p_owner_id: OWNER, p_reason: "Certificate shows 4 hours" }); }); + + it("preserves the loaded version through the full-edit route and reports a concurrent writer conflict", async () => { + mocks.from + .mockReturnValueOnce(chain({ data: { transcribed_at: null, source_url: null }, error: null })) + .mockReturnValueOnce(chain({ data: yearRow(), error: null })) + .mockReturnValueOnce(chain({ data: [requirementRow], error: null })); + mocks.rpc.mockResolvedValue({ data: null, error: { message: "cme_entry_conflict" } }); + const response = await patchEntry( + patch({ ...entry, expectedVersion: cmeEntryVersion(entry), title: "Correction" }), + context, + ); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ code: "cme_entry_conflict" }); + expect(mocks.rpc).toHaveBeenCalledWith( + "cme_save_entry_v2", + expect.objectContaining({ + p_owner_id: OWNER, + p_year_id: YEAR_ID, + p_entry_id: ENTRY_ID, + p_entry: expect.objectContaining({ expectedVersion: cmeEntryVersion(entry), title: "Correction" }), + }), + ); + }); + + it("rejects an older amendment client without a loaded version before issuing a write", async () => { + const response = await patchEntry(patch({ ...entry, amendmentReason: "Synthetic correction" }), context); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ code: "cme_entry_conflict" }); + expect(mocks.rpc).not.toHaveBeenCalled(); + }); }); describe("the year-close migration", () => { diff --git a/tests/daily-health-summary.test.ts b/tests/daily-health-summary.test.ts index f1cfba0652..baea8ef579 100644 --- a/tests/daily-health-summary.test.ts +++ b/tests/daily-health-summary.test.ts @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; import { describe, expect, it } from "vitest"; import { @@ -17,7 +18,10 @@ import { TREND_TITLE } from "../scripts/lib/eval-canary-trend.mjs"; const NOW = "2026-10-25T23:00:00Z"; // Monday 26 October 2026, 7 am in Perth const source = (key: string) => SUMMARY_SOURCES.find((item) => item.key === key)!; -const run = (hoursAgo: number) => ({ updated_at: new Date(Date.parse(NOW) - hoursAgo * 3_600_000).toISOString() }); +const run = (hoursAgo: number) => ({ + updated_at: new Date(Date.parse(NOW) - hoursAgo * 3_600_000).toISOString(), + conclusion: "success", +}); const issue = (fields: Record) => ({ number: 41, html_url: "https://github.com/BigSimmo/PsychSift/issues/41", @@ -27,6 +31,61 @@ const issue = (fields: Record) => ({ }); describe("daily health summary: judging each check", () => { + it.each(["failure", "cancelled", "timed_out", "action_required", "skipped", "neutral", null, undefined])( + "does not turn a fresh %s detector run with no issue green", + (conclusion) => { + const result = assessSource(source("errors"), { + latestRun: { ...run(0.2), conclusion }, + issue: null, + now: NOW, + }); + expect(result).toMatchObject({ + status: "amber", + detail: "latest monitoring run did not succeed; health is unassessed", + }); + }, + ); + + it("retains a service problem during detector failure and clears only detector failure after recovery", () => { + const failed = assessSource(source("live-site"), { + latestRun: { ...run(1), conclusion: "failure" }, + issue: issue({}), + now: NOW, + }); + expect(failed).toMatchObject({ + status: "red", + detail: "latest monitoring run did not succeed; last known: is failing its checks", + }); + const recovered = assessSource(source("live-site"), { latestRun: run(0.1), issue: issue({}), now: NOW }); + expect(recovered).toMatchObject({ status: "red", detail: "is failing its checks" }); + expect(assessSource(source("errors"), { latestRun: run(0.1), issue: null, now: NOW }).status).toBe("green"); + }); + + it("links an unavailable detector run when it could not publish an issue", () => { + const result = assessSource(source("errors"), { + latestRun: { ...run(0.2), conclusion: "failure", html_url: "https://github.com/o/r/actions/runs/42" }, + now: NOW, + }); + expect(result.link).toBe("[run](https://github.com/o/r/actions/runs/42)"); + }); + + it("keeps a failed run reachable alongside an older open issue", () => { + const result = assessSource(source("live-site"), { + latestRun: { ...run(0.2), conclusion: "failure", html_url: "https://github.com/o/r/actions/runs/42" }, + issue: issue({}), + now: NOW, + }); + expect(result.link).toBe( + "[#41](https://github.com/BigSimmo/PsychSift/issues/41) · [run](https://github.com/o/r/actions/runs/42)", + ); + const passing = assessSource(source("live-site"), { + latestRun: { ...run(0.2), html_url: "https://github.com/o/r/actions/runs/43" }, + issue: issue({}), + now: NOW, + }); + expect(passing.link).toBe("[#41](https://github.com/BigSimmo/PsychSift/issues/41)"); + }); + it("calls a check green when its workflow ran on time and its issue is closed", () => { const result = assessSource(source("live-site"), { latestRun: run(2), @@ -158,7 +217,10 @@ describe("daily health summary: judging each check", () => { botActivityAt: failedAt, now: NOW, }); - expect(stillFailing).toMatchObject({ status: "red", detail: "the last weekly canary failed" }); + expect(stillFailing).toMatchObject({ + status: "red", + detail: "latest monitoring run did not succeed; last known: the last weekly canary failed", + }); }); it("says too few answers were seen rather than 'within limits' on a quiet day (EM-N2)", () => { @@ -352,6 +414,35 @@ describe("daily health summary: reading GitHub", () => { expect(body).not.toContain("secret error text"); }); + it.each([ + ["a failed pull-request run is newer than a passing main push", "success", "failure", "green"], + ["a passing pull-request run is newer than a failed main push", "failure", "success", "amber"], + ])("judges CI on main by main pushes only when %s", async (_label, mainConclusion, prConclusion, expected) => { + const calls: Array> = []; + const mainRun = { ...run(3), conclusion: mainConclusion, event: "push", head_branch: "main" }; + const prRun = { ...run(1), conclusion: prConclusion, event: "pull_request", head_branch: "feature" }; + const github = { + rest: { + actions: { + listWorkflowRuns: async (params: Record) => { + if (params.workflow_id !== "ci.yml") return { data: { workflow_runs: [run(1)] } }; + calls.push(params); + const runs = [prRun, mainRun].filter( + (item) => + (params.event === undefined || item.event === params.event) && + (params.branch === undefined || item.head_branch === params.branch), + ); + return { data: { workflow_runs: runs.slice(0, Number(params.per_page ?? 30)) } }; + }, + }, + issues: { listForRepo: async () => ({ data: [] }), listComments: async () => ({ data: [] }) }, + }, + }; + const assessments = await collectAssessments({ github, owner: "BigSimmo", repo: "PsychSift", now: NOW }); + expect(calls[0]).toMatchObject({ branch: "main", event: "push" }); + expect(assessments.find((item) => item.key === "ci-main")?.status).toBe(expected); + }); + it("marks a check red when GitHub cannot be read, rather than dropping it", async () => { const github = { rest: { @@ -448,4 +539,22 @@ describe("daily health summary workflow", () => { expect(workflow).toContain("PROD_HEALTH_URL: ${{ vars.PROD_HEALTH_URL }}"); expect(workflow).toContain("HEALTH_DEEP_PROBE_SECRET: ${{ secrets.HEALTH_DEEP_PROBE_SECRET }}"); }); + + it("fails the Sentry detector before any API call when its token is missing", async () => { + const sentryWorkflow = readFileSync(".github/workflows/sentry-alerts.yml", "utf8"); + const script = sentryWorkflow + .split(" script: |\n")[1]! + .split("\n") + .map((line) => line.replace(/^ {12}/, "")) + .join("\n"); + const failures: string[] = []; + await runInNewContext(`(async () => {${script}\n})()`, { + process: { env: {} }, + core: { warning: () => {}, setFailed: (message: string) => failures.push(message) }, + fetch: () => { + throw new Error("missing configuration must not call a provider"); + }, + }); + expect(failures).toEqual(["SENTRY_ALERTS_TOKEN is not set; Sentry monitoring is unavailable."]); + }); }); diff --git a/tests/deep-memory.test.ts b/tests/deep-memory.test.ts index d916e0b0d5..76ebaef363 100644 --- a/tests/deep-memory.test.ts +++ b/tests/deep-memory.test.ts @@ -34,7 +34,8 @@ const document = { title: "Future Uploaded Clinical Protocol", file_name: "future-upload.pdf", source_path: "uploads/future-upload.pdf", - metadata: {}, + metadata: { index_generation_id: "00000000-0000-4000-8000-000000000010" }, + updated_at: "snapshot-A", }; function chunk(overrides: Partial & { chunk_index?: number; page_number?: number | null }) { @@ -472,7 +473,10 @@ describe("deep RAG memory indexing", () => { const insertedMemoryRows: Record[] = []; const insertedIndexUnits: Record[] = []; const updatedRows = new Map[]>(); - const rpc = vi.fn(async () => ({ data: { committed: true }, error: null })); + const rpc = vi.fn(async () => ({ + data: { committed: true, updated_at: "snapshot-next", metadata: document.metadata }, + error: null, + })); const supabase = { from: vi.fn((table: string) => ({ insert: (payload: Record[]) => { @@ -572,13 +576,18 @@ describe("deep RAG memory indexing", () => { ).toBe(false); } expect(updatedRows.has("documents")).toBe(false); - expect(updatedRows.get("document_chunks")?.[0]?.metadata).toEqual( - expect.objectContaining({ - rag_indexing_version: ragDeepMemoryVersion, - rag_memory_version: ragDeepMemoryVersion, - }), - ); - expect(rpc).toHaveBeenCalledWith("commit_document_deep_memory_generation", { + expect(updatedRows.has("document_chunks")).toBe(false); + expect(rpc).toHaveBeenCalledWith("commit_document_maintenance_deep_memory", { + p_aliases: [], + p_owner_id: document.owner_id, + p_expected_updated_at: "snapshot-A", + p_expected_generation_id: document.metadata.index_generation_id, + p_chunk_patches: expect.arrayContaining([ + expect.objectContaining({ + id: "chunk-upsert", + metadata: expect.objectContaining({ rag_memory_version: ragDeepMemoryVersion }), + }), + ]), p_artifact_generation_id: artifactGeneration, p_document_id: document.id, p_document_intelligence_version: expect.any(String), @@ -591,6 +600,51 @@ describe("deep RAG memory indexing", () => { }); }); + it("refuses unassigned legacy artifact lineage before provider work or staging", async () => { + generateModelIndexProfileMock.mockClear(); + const supabase = { + from: vi.fn((table: string) => ({ + select: () => ({ + eq: async () => ({ + data: + table === "document_sections" + ? [ + { + producer: "local-worker", + artifact_generation_id: null, + index_generation_id: null, + metadata: { generated_by: "local-worker" }, + }, + ] + : [], + error: null, + }), + }), + })), + }; + await expect( + upsertDocumentDeepMemory({ supabase: supabase as never, document, chunks: [chunk({})] }), + ).rejects.toThrow("legacy_generation_lineage_requires_fenced_reindex"); + expect(generateModelIndexProfileMock).not.toHaveBeenCalled(); + }); + + it.each([{}, { index_generation_id: "" }, { index_generation_id: "not-a-uuid" }])( + "refuses a snapshot with no committed generation (%j) before provider work or staging", + async (metadata) => { + generateModelIndexProfileMock.mockClear(); + const supabase = { from: vi.fn(() => ({ select: () => ({ eq: async () => ({ data: [], error: null }) }) })) }; + await expect( + upsertDocumentDeepMemory({ + supabase: supabase as never, + document: { ...document, metadata }, + chunks: [chunk({})], + }), + ).rejects.toThrow("has no committed index generation"); + expect(generateModelIndexProfileMock).not.toHaveBeenCalled(); + expect(supabase.from).not.toHaveBeenCalled(); + }, + ); + it("rejects mixed local and agent artifacts before model generation, embeddings, or deletion", async () => { generateModelIndexProfileMock.mockClear(); const deleteSpy = vi.fn(); diff --git a/tests/document-enrichment.test.ts b/tests/document-enrichment.test.ts index c614e66c36..c96374fb1f 100644 --- a/tests/document-enrichment.test.ts +++ b/tests/document-enrichment.test.ts @@ -108,6 +108,20 @@ function createSupabaseMock() { const supabase = { calls, + rpc: vi.fn(async (_name: string, payload: Record) => { + const replacement = payload.p_payload as { + summary?: Record; + metadata_patch?: Record; + }; + return { + data: { + updated_at: "snapshot-next", + metadata: { existing: true, ...replacement.metadata_patch }, + summary: { id: "summary-1", ...replacement.summary }, + }, + error: null, + }; + }), from: vi.fn((table: string) => { const call: QueryCall = { table, operation: "select", filters: [] }; calls.push(call); @@ -166,6 +180,39 @@ describe("document enrichment", () => { vi.restoreAllMocks(); }); + it("does not commit partial summary/label writes when atomic replacement rejects the snapshot", async () => { + const supabase = { + ...createSupabaseMock(), + rpc: vi.fn().mockResolvedValue({ data: null, error: { message: "maintenance_snapshot_conflict" } }), + }; + await expect( + upsertDocumentEnrichment({ + supabase: supabase as never, + document: { + id: "doc-stale", + owner_id: "owner", + title: "Synthetic protocol", + file_name: "synthetic.pdf", + source_path: null, + metadata: { index_generation_id: "generation-A" }, + updated_at: "snapshot-A", + } as never, + chunks: [ + { + id: "chunk-A", + page_number: 1, + chunk_index: 0, + section_heading: null, + content: "Synthetic source content.", + }, + ], + images: [], + }), + ).rejects.toThrow("maintenance_snapshot_conflict"); + expect(supabase.calls.filter((call) => call.operation !== "select")).toEqual([]); + expect(supabase.rpc).toHaveBeenCalledTimes(1); + }); + it("writes the current RAG enrichment version to summaries, labels, and document metadata", async () => { const supabase = createSupabaseMock(); @@ -178,6 +225,7 @@ describe("document enrichment", () => { file_name: "future-upload.pdf", source_path: null, metadata: { existing: true }, + updated_at: "snapshot-A", }, chunks: [ { @@ -191,35 +239,40 @@ describe("document enrichment", () => { images: [], }); - const summaryUpsert = supabase.calls.find( - (call) => call.table === "document_summaries" && call.operation === "upsert", - ); - const labelsInsert = supabase.calls.find((call) => call.table === "document_labels" && call.operation === "insert"); - const documentUpdate = supabase.calls.find((call) => call.table === "documents" && call.operation === "update"); - - expect((summaryUpsert?.payload as { metadata: Record }).metadata).toMatchObject({ + const payload = supabase.rpc.mock.calls[0]?.[1]; + const replacement = payload?.p_payload as { + summary: { metadata: Record }; + labels: unknown[]; + metadata_patch: Record; + }; + expect(supabase.rpc.mock.calls[0]?.[0]).toBe("replace_document_maintenance_artifacts"); + expect(payload).toMatchObject({ + p_document_id: "doc-future", + p_owner_id: null, + p_expected_updated_at: "snapshot-A", + p_expected_generation_id: null, + }); + expect(replacement.summary.metadata).toMatchObject({ generated_by: "local-worker", rag_enrichment_version: ragEnrichmentVersion, clinical_profile_version: "clinical-document-profile-v1", label_count: expect.any(Number), coverage_profile: expect.objectContaining({ chunk_count: 1 }), }); - expect(labelsInsert?.payload).toEqual( + expect(replacement.labels).toEqual( expect.arrayContaining([ expect.objectContaining({ - document_id: "doc-future", - source: "generated", metadata: expect.objectContaining({ rag_enrichment_version: ragEnrichmentVersion }), }), ]), ); - expect((documentUpdate?.payload as { metadata: Record }).metadata).toMatchObject({ - existing: true, + expect(replacement.metadata_patch).toMatchObject({ rag_enrichment_version: ragEnrichmentVersion, generated_label_count: expect.any(Number), coverage_profile: expect.objectContaining({ chunk_count: 1 }), }); - expect(documentUpdate?.filters).toContainEqual({ column: "id", value: "doc-future" }); + expect(replacement.metadata_patch).not.toHaveProperty("index_generation_id"); + expect(supabase.calls).toEqual([]); }); it("cleans noisy generated labels before inserting document labels", async () => { @@ -249,6 +302,7 @@ describe("document enrichment", () => { file_name: "clozapine-monitoring.pdf", source_path: null, metadata: {}, + updated_at: "snapshot-A", }, chunks: [ { @@ -262,8 +316,10 @@ describe("document enrichment", () => { images: [], }); - const labelsInsert = supabase.calls.find((call) => call.table === "document_labels" && call.operation === "insert"); - const labels = (labelsInsert?.payload as Array<{ label: string; label_type: string }>) ?? []; + const replacement = supabase.rpc.mock.calls[0]?.[1]?.p_payload as { + labels: Array<{ label: string; label_type: string }>; + }; + const labels = replacement.labels; expect(labels).toEqual( expect.arrayContaining([ diff --git a/tests/drift-detection.test.ts b/tests/drift-detection.test.ts index 3979061959..7583b8743b 100644 --- a/tests/drift-detection.test.ts +++ b/tests/drift-detection.test.ts @@ -35,6 +35,7 @@ describe("drift manifest freshness (offline half of check:drift)", () => { "constraints", "triggers", "storage_buckets", + "private_objects", ]) { expect(Array.isArray(snapshot[category]), `manifest.snapshot.${category} missing`).toBe(true); expect((snapshot[category] as unknown[]).length, `manifest.snapshot.${category} empty`).toBeGreaterThan(0); @@ -44,6 +45,7 @@ describe("drift manifest freshness (offline half of check:drift)", () => { expect((snapshot.functions as unknown[]).length).toBeGreaterThan(40); expect((snapshot.indexes as unknown[]).length).toBeGreaterThan(100); expect((snapshot.tables as unknown[]).length).toBeGreaterThan(30); + expect((snapshot.private_objects as unknown[]).length).toBeGreaterThan(17); }); it("manifest snapshot is v2 with the migration-history probe (empty on replay — no history table)", () => { @@ -75,7 +77,7 @@ describe("local drift replay container safety", () => { describe("schema_drift_snapshot definition parity (migration vs schema.sql)", () => { const extract = (text: string) => { - const start = text.indexOf("create or replace function public.schema_drift_snapshot()"); + const start = text.lastIndexOf("create or replace function public.schema_drift_snapshot()"); expect(start, "schema_drift_snapshot definition not found").toBeGreaterThanOrEqual(0); const end = text.indexOf("grant execute on function public.schema_drift_snapshot() to service_role;", start); expect(end, "schema_drift_snapshot grants not found").toBeGreaterThan(start); @@ -83,7 +85,7 @@ describe("schema_drift_snapshot definition parity (migration vs schema.sql)", () }; // The newest migration that redefines schema_drift_snapshot(); schema.sql must mirror it. - const LATEST_DEFINER_MIGRATION = "20261021180000_schema_drift_snapshot_literal_safe_comments.sql"; + const LATEST_DEFINER_MIGRATION = "20261107000100_audit_private_evidence_lifecycle_and_drift.sql"; it(`migration ${LATEST_DEFINER_MIGRATION.slice(0, 14)} (latest definer) and schema.sql carry byte-identical definitions`, () => { const fromMigration = extract(read(`supabase/migrations/${LATEST_DEFINER_MIGRATION}`)); diff --git a/tests/drift-private-inventory.test.ts b/tests/drift-private-inventory.test.ts new file mode 100644 index 0000000000..9a17c8779b --- /dev/null +++ b/tests/drift-private-inventory.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +// A controller-owned immutable baseline copy can demonstrate the regression without +// changing the working source under another reviewer or test process. +const { compareDriftSnapshots }: typeof import("../scripts/check-drift") = await import( + process.env.DATA_DRIFT_BASELINE_MODULE ?? "../scripts/check-drift" +); + +const relationIdentity = "relation:ingestion_generation_evidence.retained_artifacts"; +const snapshot = (): { private_objects: Array<{ identity: string; state: Record }> } => ({ + private_objects: [ + { + identity: relationIdentity, + state: { + owner: "postgres", + acl: ["postgres=arwdDxtm/postgres"], + rls_enabled: true, + rls_forced: false, + triggers: [{ name: "evidence_immutable", enabled: "O", definition: "immutable()" }], + constraints: [{ name: "bytes_hash", validated: true, definition: "CHECK (hash_matches)" }], + }, + }, + { + identity: "function:ingestion_generation_evidence.immutable()", + state: { owner: "postgres", def_hash: "before", config: ['search_path=""'], acl: [] }, + }, + { + identity: "default_acl:postgres:ingestion_generation_evidence:f", + state: { acl: ["postgres=X/postgres"] }, + }, + ], +}); + +describe("application-owned private schema drift", () => { + it("keeps public-only snapshot compatibility while both sides lack the private inventory", () => { + expect(compareDriftSnapshots({}, {}, []).findings).toEqual([]); + }); + + it("fails closed when an older live probe omits the expected private inventory", () => { + const result = compareDriftSnapshots(snapshot(), {}, []); + expect(result.findings).toHaveLength(3); + expect(result.findings.every((f) => f.category === "private_objects" && f.kind === "missing_live")).toBe(true); + }); + + it.each([ + ["owner", "other_owner"], + ["acl", ["authenticated=r/postgres"]], + ["rls_enabled", false], + ["rls_forced", true], + ["triggers", [{ name: "evidence_immutable", enabled: "D", definition: "immutable()" }]], + ["constraints", [{ name: "bytes_hash", validated: false, definition: "CHECK (hash_matches)" }]], + ])("detects a private-only %s change", (field, value) => { + const live = snapshot(); + Object.assign(live.private_objects[0].state, { [String(field)]: value }); + expect(compareDriftSnapshots(snapshot(), live, []).findings).toEqual([ + expect.objectContaining({ category: "private_objects", kind: "mismatch", key: relationIdentity }), + ]); + }); + + it.each(["def_hash", "config", "acl"])("detects private function %s drift", (field) => { + const live = snapshot(); + Object.assign(live.private_objects[1].state, { [field]: field === "def_hash" ? "after" : ["changed"] }); + expect(compareDriftSnapshots(snapshot(), live, []).findings).toEqual([ + expect.objectContaining({ kind: "mismatch", key: "function:ingestion_generation_evidence.immutable()" }), + ]); + }); + + it("compares schema-scoped default privileges and schema-qualified additions", () => { + const live = snapshot(); + Object.assign(live.private_objects[2].state, { acl: ["authenticated=X/postgres"] }); + live.private_objects.push({ identity: "relation:ingestion_generation_evidence.unexpected", state: {} }); + const result = compareDriftSnapshots(snapshot(), live, []); + expect(result.findings).toEqual([ + expect.objectContaining({ kind: "mismatch", key: "default_acl:postgres:ingestion_generation_evidence:f" }), + expect.objectContaining({ kind: "unexpected_live", key: "relation:ingestion_generation_evidence.unexpected" }), + ]); + expect(result.allowed).toEqual([]); + }); +}); diff --git a/tests/enrich-documents-guards.test.ts b/tests/enrich-documents-guards.test.ts index 70b21ab6d5..ab14b678cd 100644 --- a/tests/enrich-documents-guards.test.ts +++ b/tests/enrich-documents-guards.test.ts @@ -53,7 +53,7 @@ describe("enrich:documents guards", () => { expect(projectCheck).toBeLessThan(adminClient); const dryRunGate = main.indexOf("if (!args.write)"); - const firstWrite = main.indexOf(".update("); + const firstWrite = main.indexOf("patchMaintenanceDocument("); expect(dryRunGate, "the dry-run gate must exist in main").toBeGreaterThan(-1); expect(firstWrite).toBeGreaterThan(dryRunGate); expect(main.indexOf("upsertDocumentEnrichment(")).toBeGreaterThan(dryRunGate); diff --git a/tests/env.test.ts b/tests/env.test.ts index 21c80248f3..a10a4ab511 100644 --- a/tests/env.test.ts +++ b/tests/env.test.ts @@ -1,5 +1,26 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +describe("optional Supabase URL environment parsing", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("treats blank offline URLs as absent while keeping required server configuration fail-closed", async () => { + vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", " "); + vi.stubEnv("SUPABASE_DB_URL", ""); + const { env, requireServerEnv } = await import("@/lib/env"); + expect(env.NEXT_PUBLIC_SUPABASE_URL).toBeUndefined(); + expect(env.SUPABASE_DB_URL).toBeUndefined(); + expect(requireServerEnv).toThrow("Missing server environment variables"); + }); + + it.each(["NEXT_PUBLIC_SUPABASE_URL", "SUPABASE_DB_URL"])("still rejects a malformed nonblank %s", async (key) => { + vi.stubEnv(key, "invalid-url"); + await expect(import("@/lib/env")).rejects.toThrow(); + }); +}); + describe("Sol answer defaults and workload isolation", () => { afterEach(() => { vi.unstubAllEnvs(); diff --git a/tests/epa-magic-link-repository.test.ts b/tests/epa-magic-link-repository.test.ts index 3191a4a0b0..9008c06752 100644 --- a/tests/epa-magic-link-repository.test.ts +++ b/tests/epa-magic-link-repository.test.ts @@ -15,8 +15,20 @@ function fakeClient() { const filters: Array<(row: EpaMagicLinkRow) => boolean> = []; let limit = Infinity; let single = false; + const ordering: { column: keyof EpaMagicLinkRow; ascending: boolean }[] = []; const run = () => { - const matched = rows.filter((row) => filters.every((f) => f(row))).slice(0, limit); + const matched = rows + .filter((row) => filters.every((f) => f(row))) + .sort((left, right) => { + for (const { column, ascending } of ordering) { + const a = String(left[column]), + b = String(right[column]); + const compared = a.localeCompare(b); + if (compared) return ascending ? compared : -compared; + } + return 0; + }) + .slice(0, limit); if (op.kind === "update") for (const row of matched) Object.assign(row, op.values); const data = single ? (matched[0] ? { ...matched[0] } : null) : matched.map((row) => ({ ...row })); return { data: op.head ? null : data, error: null, count: matched.length }; @@ -24,7 +36,16 @@ function fakeClient() { const builder = { eq: (column: keyof EpaMagicLinkRow, value: string) => (filters.push((r) => r[column] === value), builder), gt: (column: keyof EpaMagicLinkRow, value: string) => (filters.push((r) => String(r[column]) > value), builder), - order: () => builder, + order: (column: keyof EpaMagicLinkRow, options?: { ascending?: boolean }) => ( + ordering.push({ column, ascending: options?.ascending ?? true }), + builder + ), + or: (filter: string) => { + const match = /^created_at\.lt\.([^,]+),and\(created_at\.eq\.([^,]+),id\.lt\.([^)]+)\)$/.exec(filter); + if (!match) throw new Error("Unexpected cursor predicate"); + filters.push((row) => row.created_at < match[1]! || (row.created_at === match[2]! && row.id < match[3]!)); + return builder; + }, limit: (count: number) => ((limit = count), builder), select: () => builder, maybeSingle: () => ((single = true), builder), @@ -34,6 +55,22 @@ function fakeClient() { return builder; }; const client = { + rpc: async (name: string, args: { p_registrar_id: string; p_link: Partial }) => { + if (name !== "teaching_create_epa_magic_link_v1") throw new Error("Unexpected RPC"); + const at = String(args.p_link.updated_at); + if ( + rows.filter( + (row) => row.registrar_id === args.p_registrar_id && row.status === "pending" && row.expires_at > at, + ).length >= EPA_MAGIC_LINK_MAX_PENDING + ) { + return { data: null, error: { code: "P0001", message: "magic_link_limit_reached" } }; + } + return client + .from() + .insert({ ...args.p_link, registrar_id: args.p_registrar_id } as never) + .select() + .single(); + }, from: () => ({ select: (_columns?: string, options?: { head?: boolean }) => query({ kind: "select", head: options?.head }), update: (values: Partial) => query({ kind: "update", values }), @@ -70,6 +107,59 @@ const answer = { } as const; describe("durable EPA feedback links (#MAAXAX)", () => { + it("enforces the existing pending ceiling when concurrent preflights all observe room", async () => { + const { client, rows } = fakeClient(); + const repo = new DurableEpaMagicLinkRepository(client); + const results = await Promise.allSettled( + Array.from({ length: EPA_MAGIC_LINK_MAX_PENDING + 1 }, () => repo.create(DOCTOR, input, NOW)), + ); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(EPA_MAGIC_LINK_MAX_PENDING); + expect(results.filter((result) => result.status === "rejected")).toEqual([ + expect.objectContaining({ reason: expect.objectContaining({ code: "magic_link_limit_reached" }) }), + ]); + expect(rows).toHaveLength(EPA_MAGIC_LINK_MAX_PENDING); + }); + + it("keeps older active links cancellable while owner history pages without loss or duplication", async () => { + const { client, rows } = fakeClient(); + const repo = new DurableEpaMagicLinkRepository(client); + const made = await repo.create(DOCTOR, input, NOW); + const template = { ...rows[0]! }; + for (let index = 1; index <= 121; index += 1) + rows.push({ + ...template, + id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`, + token_hash: String(index).padStart(64, "0"), + status: "completed", + completed_at: NOW.toISOString(), + response: { ...answer, complexity: null, assessorName: "Dr Synthetic Example", submittedAt: NOW.toISOString() }, + created_at: "2026-10-22T00:00:00.000Z", + }); + rows.push({ ...template, id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", registrar_id: OTHER }); + const first = await repo.listForRegistrarPage(DOCTOR, NOW); + expect(first.links).toContainEqual(expect.objectContaining({ id: made.link.id, status: "pending" })); + expect(first.links.length).toBeLessThanOrEqual(51); + expect(first.nextCursor).toEqual(expect.any(String)); + const found = new Map(first.links.map((link) => [link.id, link])); + let cursor = first.nextCursor; + while (cursor) { + const page = await repo.listForRegistrarPage(DOCTOR, NOW, cursor); + for (const link of page.links) found.set(link.id, link); + cursor = page.nextCursor; + } + expect(found.size).toBe(122); + expect(found.has("eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")).toBe(false); + expect(JSON.stringify([...found.values()])).not.toContain("token_hash"); + expect(await repo.revoke(DOCTOR, made.link.id, NOW)).toBe(true); + }); + + it("rejects malformed history cursors before issuing a query", async () => { + const { client } = fakeClient(); + await expect( + new DurableEpaMagicLinkRepository(client).listForRegistrarPage(DOCTOR, NOW, "invalid,(filter)"), + ).rejects.toMatchObject({ status: 400 }); + }); + it("stores only the token's hash, expires in 14 days, and shows the link only to its doctor", async () => { const { client, rows } = fakeClient(); const repo = new DurableEpaMagicLinkRepository(client); diff --git a/tests/ingestion-import-queue.test.ts b/tests/ingestion-import-queue.test.ts new file mode 100644 index 0000000000..4f285eb05c --- /dev/null +++ b/tests/ingestion-import-queue.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from "vitest"; +import { assertExistingImportDocument, queueExistingImportDocument } from "../scripts/lib/import-document-queue"; + +const input = { + documentId: "doc", + ownerId: "owner", + contentHash: "hash", + batchId: "batch", + force: true, + maxAttempts: 3, +}; +describe("bulk import existing-document queue", () => { + it("uses one atomic owner/hash scoped operation for force, without destructive reset or separate writes", async () => { + const rpc = vi.fn().mockResolvedValue({ data: { outcome: "enqueued" }, error: null }); + await expect(queueExistingImportDocument({ ...input, rpc })).resolves.toEqual({ outcome: "enqueued" }); + expect(rpc).toHaveBeenCalledExactlyOnceWith("enqueue_import_document", { + p_document_id: "doc", + p_owner_id: "owner", + p_content_hash: "hash", + p_batch_id: "batch", + p_force: true, + p_max_attempts: 3, + }); + }); + it("allows ordinary resume to reconcile an incomplete document instead of blindly skipping its hash", async () => { + const rpc = vi.fn().mockResolvedValue({ data: { outcome: "enqueued" }, error: null }); + await expect(queueExistingImportDocument({ ...input, force: false, rpc })).resolves.toEqual({ + outcome: "enqueued", + }); + expect(rpc.mock.calls[0][1].p_force).toBe(false); + }); + it("preserves definite failure and ambiguous replies for identity replay, without compensating deletion", async () => { + for (const result of [ + { data: null, error: { message: "insert rejected" } }, + { data: null, error: null }, + ]) { + const rpc = vi.fn().mockResolvedValue(result); + await expect(queueExistingImportDocument({ ...input, rpc })).rejects.toThrow(); + expect(rpc).toHaveBeenCalledTimes(1); + } + }); + it("refuses new backend intake before any storage or document action when admission authority is missing", () => { + expect(() => assertExistingImportDocument(undefined)).toThrow("No bytes were uploaded"); + expect(() => assertExistingImportDocument({ id: "existing" })).not.toThrow(); + }); +}); diff --git a/tests/ingestion-maintenance.test.ts b/tests/ingestion-maintenance.test.ts new file mode 100644 index 0000000000..6f40259ee6 --- /dev/null +++ b/tests/ingestion-maintenance.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it, vi } from "vitest"; +import { + IngestionMaintenanceConflict, + patchMaintenanceDocument, + replaceMaintenanceArtifacts, + commitMaintenanceDeepMemory, + type MaintenanceDocument, +} from "@/lib/ingestion-maintenance"; + +const GEN_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const GEN_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; +// The documents.index_generation_id generated column: malformed metadata maps to null. +const generatedColumn = (value: unknown) => + typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value) + ? value + : null; + +function fixture() { + let row = { + id: "doc", + owner_id: "owner", + updated_at: "A", + metadata: { index_generation_id: GEN_A as string, worker_key: "A" }, + image_count: 4, + }; + const client = { + from: () => { + const filters: Array<[string, unknown]> = []; + let patch: Record = {}; + const query = { + update(value: Record) { + patch = value; + return query; + }, + eq(key: string, value: unknown) { + filters.push([key, value]); + return query; + }, + is(key: string, value: unknown) { + filters.push([key, value]); + return query; + }, + select() { + return query; + }, + async maybeSingle() { + const current = { ...row, index_generation_id: generatedColumn(row.metadata.index_generation_id) }; + if (!filters.every(([key, value]) => current[key as keyof typeof current] === value)) + return { data: null, error: null }; + row = { ...row, ...patch, updated_at: `${row.updated_at}+` } as typeof row; + return { data: { updated_at: row.updated_at, metadata: row.metadata }, error: null }; + }, + }; + return query; + }, + }; + const snapshot = structuredClone(row) as MaintenanceDocument; + return { + client: client as never, + snapshot, + read: () => row, + replace: (patch: Partial) => { + row = { ...row, ...patch }; + }, + }; +} + +describe("maintenance document snapshot fence", () => { + it("rejects a worker B commit without rewinding generation, metadata or counts", async () => { + const f = fixture(); + f.replace({ updated_at: "B", metadata: { index_generation_id: GEN_B, worker_key: "B" }, image_count: 12 }); + await expect( + patchMaintenanceDocument({ + supabase: f.client, + document: f.snapshot, + patch: { enrichment_status: "completed" }, + imageCount: 1, + }), + ).rejects.toBeInstanceOf(IngestionMaintenanceConflict); + expect(f.read()).toMatchObject({ + updated_at: "B", + image_count: 12, + metadata: { index_generation_id: GEN_B, worker_key: "B" }, + }); + expect(f.snapshot.updated_at).toBe("A"); + }); + it("rejects same-generation competing metadata and owner changes", async () => { + for (const patch of [{ updated_at: "A2" }, { owner_id: "other" }]) { + const f = fixture(); + f.replace(patch); + await expect( + patchMaintenanceDocument({ supabase: f.client, document: f.snapshot, patch: { enrichment_status: "failed" } }), + ).rejects.toBeInstanceOf(IngestionMaintenanceConflict); + expect(f.read().metadata).not.toHaveProperty("enrichment_status"); + } + }); + it("advances only its own returned snapshot for subsequent progress/completion writes", async () => { + const f = fixture(); + for (const stage of ["processing", "completed"]) + await patchMaintenanceDocument({ supabase: f.client, document: f.snapshot, patch: { enrichment_status: stage } }); + expect(f.snapshot.updated_at).toBe("A++"); + expect(f.read().metadata).toMatchObject({ + index_generation_id: GEN_A, + worker_key: "A", + enrichment_status: "completed", + }); + }); + it("refuses generation mutation and snapshots missing their original version", async () => { + const f = fixture(); + await expect( + patchMaintenanceDocument({ supabase: f.client, document: f.snapshot, patch: { index_generation_id: "bad" } }), + ).rejects.toThrow("cannot change"); + f.snapshot.updated_at = ""; + await expect( + patchMaintenanceDocument({ supabase: f.client, document: f.snapshot, patch: {} }), + ).rejects.toBeInstanceOf(IngestionMaintenanceConflict); + }); +}); + +describe("malformed legacy generations", () => { + it.each(["", "not-a-uuid"])("fences %j like the generated column, as no committed generation", async (generation) => { + const f = fixture(); + f.replace({ metadata: { index_generation_id: generation, worker_key: "A" } }); + f.snapshot.metadata = { index_generation_id: generation, worker_key: "A" }; + await patchMaintenanceDocument({ supabase: f.client, document: f.snapshot, patch: { enrichment_status: "done" } }); + expect(f.snapshot.updated_at).toBe("A+"); + const rpc = vi.fn().mockResolvedValue({ data: { updated_at: "A++", metadata: {} }, error: null }); + await replaceMaintenanceArtifacts({ + supabase: { rpc } as never, + document: f.snapshot, + operation: "labels", + payload: {}, + }); + expect(rpc).toHaveBeenCalledWith( + "replace_document_maintenance_artifacts", + expect.objectContaining({ p_expected_generation_id: null }), + ); + }); +}); + +describe("maintenance artifact transaction dispatch", () => { + it("submits the original identity and snapshot in one RPC, then advances only its own returned snapshot", async () => { + const f = fixture(); + const rpc = vi.fn().mockResolvedValue({ + data: { updated_at: "A+", metadata: { ...(f.snapshot.metadata as object), own_key: true } }, + error: null, + }); + await replaceMaintenanceArtifacts({ + supabase: { rpc } as never, + document: f.snapshot, + operation: "labels", + payload: { labels: [] }, + }); + expect(rpc).toHaveBeenCalledExactlyOnceWith("replace_document_maintenance_artifacts", { + p_document_id: "doc", + p_owner_id: "owner", + p_expected_updated_at: "A", + p_expected_generation_id: GEN_A, + p_operation: "labels", + p_payload: { labels: [] }, + }); + expect(f.snapshot.updated_at).toBe("A+"); + }); + it("does not refresh a rejected or uncertain snapshot and never attempts compensation", async () => { + for (const result of [ + { data: null, error: { message: "maintenance_snapshot_conflict" } }, + { data: null, error: null }, + ]) { + const f = fixture(); + const rpc = vi.fn().mockResolvedValue(result); + await expect( + replaceMaintenanceArtifacts({ + supabase: { rpc } as never, + document: f.snapshot, + operation: "visual", + payload: {}, + }), + ).rejects.toThrow(); + expect(f.snapshot.updated_at).toBe("A"); + expect(rpc).toHaveBeenCalledTimes(1); + } + }); + it("refuses managed evidence before dispatching either legacy mutation", async () => { + const f = fixture(); + f.snapshot.metadata = { admission_event_id: "managed", index_generation_id: GEN_A }; + const rpc = vi.fn(); + await expect( + replaceMaintenanceArtifacts({ + supabase: { rpc } as never, + document: f.snapshot, + operation: "labels", + payload: {}, + }), + ).rejects.toThrow("fenced worker"); + await expect( + commitMaintenanceDeepMemory({ supabase: { rpc } as never, document: f.snapshot, payload: {} }), + ).rejects.toThrow("fenced worker"); + expect(rpc).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/on-call-account-cache.dom.test.tsx b/tests/on-call-account-cache.dom.test.tsx new file mode 100644 index 0000000000..ba3c2c376f --- /dev/null +++ b/tests/on-call-account-cache.dom.test.tsx @@ -0,0 +1,118 @@ +/** @vitest-environment jsdom */ +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { ReactNode } from "react"; +vi.mock("next/link", () => ({ + default: ({ children, href }: { children: ReactNode; href: string }) => {children}, +})); +vi.mock("@/components/information-page-shell", () => ({ + InformationPageShell: ({ children }: { children: ReactNode }) =>
{children}
, +})); +vi.mock("@/components/on-call/on-call-nav-header", () => ({ OnCallToolNavHeader: () => null })); +vi.mock("@/components/work-time/use-work-time-zone", () => ({ useWorkTimeZone: () => ({ zone: "Australia/Perth" }) })); +vi.mock("@/lib/on-call/entry-store", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useOnCallEntries: () => ({ + entries: actual.readCachedOnCallEntries()?.entries ?? [], + loading: false, + isOffline: false, + loadError: null, + retry: () => {}, + cachedAt: null, + signedOut: false, + demoMode: false, + }), + }; +}); +import { OnCallCheckPage } from "@/components/on-call/on-call-check-page"; +import { cacheOnCallEntries, clearOnCallEntryCache, readCachedOnCallEntries } from "@/lib/on-call/entry-store"; +import { onCallEntryFixture } from "./helpers/on-call-entry-fixture"; +const A = onCallEntryFixture({ + section: "contacts", + title: "Synthetic A private entry", + updatedAt: "2026-11-05T00:00:00.123456+00:00", + details: { role: "Synthetic contact", phone: "0000" }, + lastVerifiedAt: null, +}); +const verified = { ...A, lastVerifiedAt: "2026-11-05T01:00:00Z" }; +beforeEach(() => clearOnCallEntryCache()); +afterEach(() => { + cleanup(); + clearOnCallEntryCache(); + vi.restoreAllMocks(); +}); +it("same-account control: actual confirm button updates the real cache", async () => { + cacheOnCallEntries([A]); + vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ entry: verified })); + render(); + fireEvent.click(screen.getByRole("button", { name: "Still correct" })); + await waitFor(() => expect(readCachedOnCallEntries()?.entries[0]?.lastVerifiedAt).toBe(verified.lastVerifiedAt)); +}); +it("rejects an actual unmounted confirm callback after the account cache is cleared", async () => { + cacheOnCallEntries([A]); + let resolveSave!: (value: Response) => void; + const request = vi.spyOn(globalThis, "fetch").mockReturnValue( + new Promise((resolve) => { + resolveSave = resolve; + }), + ); + const page = render(); + fireEvent.click(screen.getByRole("button", { name: "Still correct" })); + expect(request).toHaveBeenCalledWith(`/api/on-call/entries/${A.id}/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ expectedUpdatedAt: A.updatedAt }), + }); + page.unmount(); + clearOnCallEntryCache(); + expect(readCachedOnCallEntries()).toBeNull(); + // Same auth-transition cache invalidation function used by the real Auth provider. + // No actual account or server mutation is simulated; the component's real callback resumes. + await act(async () => { + resolveSave(Response.json({ entry: verified })); + }); + expect(readCachedOnCallEntries()).toBeNull(); +}); + +it.each(["create", "patch", "delete"] as const)( + "refuses a retained %s/undo dispatch from an old session", + async (operation) => { + const { createEntry, patchEntry, deleteEntry } = await import("@/components/admin/junior/junior-shared"); + const { peekOnCallEntrySessionEpoch } = await import("@/lib/on-call/entry-cache-keys"); + const epoch = peekOnCallEntrySessionEpoch(); + clearOnCallEntryCache(); + const request = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ entry: verified })); + const promise = + operation === "create" + ? createEntry({}, epoch) + : operation === "patch" + ? patchEntry(A.id, {}, epoch) + : deleteEntry(A.id, epoch); + await expect(promise).rejects.toThrow("account changed"); + expect(request).not.toHaveBeenCalled(); + }, +); + +it.each([ + ["missing", {}], + ["incomplete", { entry: { id: A.id } }], + ["wrong id", { entry: { ...verified, id: "99999999-9999-4999-8999-999999999999" } }], +])("actual check action rejects a %s successful response without poisoning the cache", async (_name, payload) => { + cacheOnCallEntries([A]); + vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(payload)); + render(); + fireEvent.click(screen.getByRole("button", { name: "Still correct" })); + await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent(/response.*invalid.*try again/i)); + expect(readCachedOnCallEntries()?.entries).toEqual([A]); +}); + +it("refuses a versionless legacy row in Check these before verification dispatch", async () => { + cacheOnCallEntries([{ ...A, updatedAt: undefined }]); + const request = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ entry: verified })); + render(); + fireEvent.click(screen.getByRole("button", { name: "Still correct" })); + expect(request).not.toHaveBeenCalled(); + expect(screen.getByRole("status")).toHaveTextContent(/reload/i); +}); diff --git a/tests/on-call-api-contract.test.ts b/tests/on-call-api-contract.test.ts index c5b28319a5..d0a7b0074f 100644 --- a/tests/on-call-api-contract.test.ts +++ b/tests/on-call-api-contract.test.ts @@ -119,6 +119,12 @@ describe("On Call entry PATCH schema (updateOnCallEntrySchema)", () => { lastVerifiedAt: "2026-01-01T00:00:00.000Z", }; + it("rejects the server-only timestamp in a PATCH body", () => { + expect( + updateOnCallEntrySchema.safeParse({ ...completeBody, updatedAt: "2026-11-05T12:00:00.123456+00:00" }).success, + ).toBe(false); + }); + it("accepts a complete PATCH body", () => { expect(updateOnCallEntrySchema.safeParse(completeBody).success).toBe(true); }); diff --git a/tests/on-call-editor.dom.test.tsx b/tests/on-call-editor.dom.test.tsx index 4c2c0bef0c..b6d144a7a5 100644 --- a/tests/on-call-editor.dom.test.tsx +++ b/tests/on-call-editor.dom.test.tsx @@ -1,12 +1,13 @@ /** @vitest-environment jsdom */ -import { cleanup, render, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useState } from "react"; import { OnCallEntryEditor, OnCallVerifyButton } from "@/components/on-call/on-call-entry-editor"; import { OnCallFreshnessBadge } from "@/components/on-call/on-call-freshness-badge"; +import { clearOnCallEntryCache } from "@/lib/on-call/entry-cache-keys"; import { COMPLIANCE_KIND } from "@/lib/on-call/compliance"; import { ON_CALL_RECURRENCE_FREQUENCIES, onCallEntryFreshness, type OnCallEntry } from "@/lib/on-call/entry-model"; import { ROLE_EXPLAINER_KIND } from "@/lib/on-call/who-is-who"; @@ -42,6 +43,7 @@ const STALE_ANAESTHETIST: OnCallEntry = { id: "33333333-3333-4333-8333-333333333333", section: "contacts", slug: "on-call-anaesthetist", + updatedAt: "2026-11-05T00:00:00.123456+00:00", title: "On-call anaesthetist", subtitle: null, body: null, @@ -54,6 +56,69 @@ const STALE_ANAESTHETIST: OnCallEntry = { lastVerifiedAt: new Date("2020-01-01T00:00:00.000Z").toISOString(), }; +describe("On Call editor account transitions", () => { + it("closes a retained draft on a session change so it cannot be saved as the next account", async () => { + const user = userEvent.setup(); + const request = vi.spyOn(globalThis, "fetch"); + const props = { open: true, section: "contacts" as const, entry: null, onSaved: vi.fn(), onClose: vi.fn() }; + const view = render(); + await user.type(screen.getByLabelText(/^Title/), "Synthetic account A private draft"); + await user.type(screen.getByLabelText(/^Role/), "Synthetic role"); + act(() => clearOnCallEntryCache()); + view.rerender(); + expect(screen.queryByDisplayValue("Synthetic account A private draft")).toBeNull(); + expect(screen.queryByTestId("on-call-entry-editor-save")).toBeNull(); + expect(request).not.toHaveBeenCalled(); + // Explicitly closing/reopening gives the new account a fresh draft. + view.rerender(); + view.rerender(); + expect(screen.getByLabelText(/^Title/)).toHaveValue(""); + }); + + it.each(["save", "delete", "verify"])( + "suppresses a late %s callback and close after the account changes", + async (operation) => { + const user = userEvent.setup(); + let release!: (response: Response) => void; + const request = vi.spyOn(globalThis, "fetch").mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + const onSaved = vi.fn(); + const onDeleted = vi.fn(); + const onClose = vi.fn(); + render( + , + ); + if (operation === "save") { + await user.type(screen.getByLabelText("Subtitle"), "Synthetic update"); + await user.click(screen.getByTestId("on-call-entry-editor-save")); + } else if (operation === "verify") { + await user.click(screen.getByTestId(`on-call-verify-${STALE_ANAESTHETIST.slug}`)); + } else { + await user.click(screen.getByTestId("on-call-entry-editor-delete")); + await user.click(screen.getByRole("button", { name: `Delete ${STALE_ANAESTHETIST.title}` })); + } + expect(request).toHaveBeenCalledTimes(1); + clearOnCallEntryCache(); + await act(async () => { + release(jsonResponse({ entry: STALE_ANAESTHETIST, deleted: true })); + }); + expect(onSaved).not.toHaveBeenCalled(); + expect(onDeleted).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }, + ); +}); + describe("OnCallEntryEditor — creating", () => { it("posts a new entry with the fields entered and hands the saved entry back", async () => { const user = userEvent.setup(); @@ -236,7 +301,11 @@ describe("OnCallVerifyButton — the one-tap 'still correct' action", () => { await user.click(screen.getByTestId(`on-call-verify-${STALE_ANAESTHETIST.slug}`)); - expect(fetchMock).toHaveBeenCalledWith(`/api/on-call/entries/${STALE_ANAESTHETIST.id}/verify`, { method: "POST" }); + expect(fetchMock).toHaveBeenCalledWith(`/api/on-call/entries/${STALE_ANAESTHETIST.id}/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ expectedUpdatedAt: STALE_ANAESTHETIST.updatedAt }), + }); await waitFor(() => expect(screen.getByTestId("on-call-freshness-badge")).toHaveAttribute("data-freshness-state", "fresh"), diff --git a/tests/on-call-entry-model.test.ts b/tests/on-call-entry-model.test.ts index d1089ec7a8..7dcdf807be 100644 --- a/tests/on-call-entry-model.test.ts +++ b/tests/on-call-entry-model.test.ts @@ -3,9 +3,12 @@ import { ON_CALL_RECURRENCE_FREQUENCIES, ON_CALL_REVIEW_INTERVAL_MONTHS, isOnCallHttpUrl, + mergeOnCallEntryVerification, + onCallEntryVerificationContentMatches, onCallDetailsSchemaFor, onCallEntryFreshness, } from "@/lib/on-call/entry-model"; +import { onCallEntryFixture } from "./helpers/on-call-entry-fixture"; import { selectCardEntries } from "@/lib/on-call/card-selection"; const NOW = new Date("2026-09-04T00:00:00.000Z"); @@ -182,3 +185,35 @@ describe("link fields accept http and https only", () => { expect(isOnCallHttpUrl(undefined)).toBe(false); }); }); + +describe("verification response reconciliation", () => { + const reviewed = onCallEntryFixture({ + section: "contacts", + title: "Synthetic contact", + details: { role: "Synthetic role", phone: "5100" }, + lastVerifiedAt: "2024-01-01T00:00:00Z", + }); + it("compares content independently of JSON object-key order", () => { + expect( + onCallEntryVerificationContentMatches(reviewed, { + ...reviewed, + details: { phone: "5100", role: "Synthetic role" }, + }), + ).toBe(true); + }); + it("keeps a newer verification when an earlier verification arrives later", () => { + const current = { ...reviewed, lastVerifiedAt: "2026-11-05T12:30:00Z" }; + expect( + mergeOnCallEntryVerification(current, reviewed, { ...reviewed, lastVerifiedAt: "2026-11-05T12:00:00Z" }), + ).toEqual(current); + }); + it("does not attest content that differs in the returned server snapshot", () => { + expect( + mergeOnCallEntryVerification(reviewed, reviewed, { + ...reviewed, + title: "Changed remotely", + lastVerifiedAt: "2026-11-05T12:00:00Z", + }), + ).toBeNull(); + }); +}); diff --git a/tests/on-call-entry-store-refetch.dom.test.tsx b/tests/on-call-entry-store-refetch.dom.test.tsx new file mode 100644 index 0000000000..d482d0414a --- /dev/null +++ b/tests/on-call-entry-store-refetch.dom.test.tsx @@ -0,0 +1,55 @@ +/** @vitest-environment jsdom */ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { OnCallEntry } from "@/lib/on-call/entry-model"; +import { clearOnCallEntryCache, updateCachedOnCallEntries, useStoredOnCallEntries } from "@/lib/on-call/entry-store"; + +const entry = (id: string, title: string): OnCallEntry => ({ + id, + section: "contacts", + slug: `synthetic-${id.slice(0, 4)}`, + title, + subtitle: null, + body: null, + details: { role: "Synthetic role", phone: "0400 000 000" }, + linkedDocumentIds: [], + tags: [], + isPersonal: false, + includeOnCard: true, + sortOrder: 0, + lastVerifiedAt: "2026-08-01T00:00:00.000Z", +}); +const existing = entry("11111111-1111-4111-8111-111111111111", "Synthetic existing contact"); +const saved = entry("22222222-2222-4222-8222-222222222222", "Synthetic saved contact"); + +afterEach(() => { + clearOnCallEntryCache(); + vi.restoreAllMocks(); +}); + +describe("useStoredOnCallEntries", () => { + it("reads the list again when a save lands while the first list is loading", async () => { + let releaseFirst!: (response: Response) => void; + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockImplementationOnce(() => new Promise((resolve) => (releaseFirst = resolve))) + .mockImplementation(async () => Response.json({ entries: [existing, saved], signedOut: false })); + + const { result } = renderHook(() => useStoredOnCallEntries()); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + + // A save while the first read is in flight leaves only the new entry cached. + act(() => { + updateCachedOnCallEntries([], (current) => [...current, saved], 0); + }); + await act(async () => releaseFirst(Response.json({ entries: [existing], signedOut: false }))); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.entries.map(({ title }) => title)).toEqual([ + "Synthetic existing contact", + "Synthetic saved contact", + ]); + }); +}); diff --git a/tests/on-call-entry-store.dom.test.tsx b/tests/on-call-entry-store.dom.test.tsx index 6765d9c8d6..85fffc4653 100644 --- a/tests/on-call-entry-store.dom.test.tsx +++ b/tests/on-call-entry-store.dom.test.tsx @@ -3,6 +3,7 @@ import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { resetSharedGets } from "@/lib/shared-get"; import type { OnCallEntry } from "@/lib/on-call/entry-model"; import { setOnCallDemoPreviewActive } from "@/lib/on-call/entry-cache-keys"; import { @@ -10,6 +11,8 @@ import { cacheOnCallEntries, clearOnCallEntryCache, readCachedOnCallEntries, + updateCachedOnCallEntries, + peekOnCallEntrySessionEpoch, useStoredOnCallEntries as useOnCallEntries, } from "@/lib/on-call/entry-store"; @@ -35,16 +38,120 @@ function jsonResponse(body: unknown) { describe("useOnCallEntries", () => { beforeEach(() => { + resetSharedGets(); clearOnCallEntryCache(); vi.restoreAllMocks(); }); afterEach(() => { cleanup(); + resetSharedGets(); clearOnCallEntryCache(); vi.restoreAllMocks(); }); + it("a new reader after a mutation starts a new GET instead of joining an older one", async () => { + cacheOnCallEntries([contact]); + let release!: (response: Response) => void; + const updated = { ...contact, title: "Synthetic updated contact" }; + const request = vi + .spyOn(globalThis, "fetch") + .mockReturnValueOnce( + new Promise((resolve) => { + release = resolve; + }), + ) + .mockResolvedValueOnce(jsonResponse({ entries: [updated], signedOut: false })); + const first = renderHook(() => useOnCallEntries()); + act(() => { + cacheOnCallEntries([updated]); + }); + const second = renderHook(() => useOnCallEntries()); + expect(request).toHaveBeenCalledTimes(2); + await act(async () => { + release(jsonResponse({ entries: [contact], signedOut: false })); + }); + await waitFor(() => expect(second.result.current.loading).toBe(false)); + expect(readCachedOnCallEntries()?.entries).toEqual([updated]); + expect(first.result.current.entries).toEqual([updated]); + }); + + it("rejects a mixed malformed list as a failed refresh instead of publishing a partial list", async () => { + cacheOnCallEntries([contact]); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + jsonResponse({ + entries: [{ ...contact, title: "Synthetic replacement" }, { id: "invalid-row" }], + signedOut: false, + }), + ); + const { result } = renderHook(() => useOnCallEntries()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.loadError).toBe("failed"); + expect(result.current.entries).toEqual([contact]); + expect(readCachedOnCallEntries()?.entries).toEqual([contact]); + }); + + it("publishes shared read metadata to every mounted reader", async () => { + let release!: (response: Response) => void; + vi.spyOn(globalThis, "fetch").mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + const { result } = renderHook(() => [useOnCallEntries(), useOnCallEntries()]); + await act(async () => { + release(jsonResponse({ entries: [], signedOut: true, demoMode: true })); + }); + await waitFor(() => expect(result.current.every((reader) => !reader.loading)).toBe(true)); + expect(result.current.map((reader) => ({ signedOut: reader.signedOut, demoMode: reader.demoMode }))).toEqual([ + { signedOut: true, demoMode: true }, + { signedOut: true, demoMode: true }, + ]); + }); + + it.each(["verify", "edit", "delete"])( + "keeps a successful %s when an older list response completes", + async (operation) => { + cacheOnCallEntries([contact]); + let releaseOlder!: (response: Response) => void; + let releaseRefetch!: (response: Response) => void; + const request = vi + .spyOn(globalThis, "fetch") + .mockReturnValueOnce( + new Promise((resolve) => { + releaseOlder = resolve; + }), + ) + .mockReturnValueOnce( + new Promise((resolve) => { + releaseRefetch = resolve; + }), + ); + const { result } = renderHook(() => useOnCallEntries()); + const updated = { ...contact, title: "Synthetic updated contact", lastVerifiedAt: "2026-11-05T00:00:00.000Z" }; + const expected = operation === "delete" ? [] : [updated]; + act(() => { + updateCachedOnCallEntries([contact], () => expected, peekOnCallEntrySessionEpoch()); + }); + // The older list (read before the save) lands. It must not overwrite the + // save; the hook reads the list again and stays loading meanwhile. + await act(async () => { + releaseOlder(jsonResponse({ entries: [contact], signedOut: false })); + }); + await waitFor(() => expect(request).toHaveBeenCalledTimes(2)); + expect(result.current.loading).toBe(true); + expect(readCachedOnCallEntries()?.entries).toEqual(expected); + expect(result.current.entries).toEqual(expected); + // The fresh read reflects the server after the save. + await act(async () => { + releaseRefetch(jsonResponse({ entries: expected, signedOut: false })); + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(readCachedOnCallEntries()?.entries).toEqual(expected); + expect(result.current.entries).toEqual(expected); + }, + ); + it("leaves an active on-device preview alone when a NON-EMPTY shared response arrives", async () => { // The defect this exists for (Codex, 2026-09-22). The empty-response guard // below protects a shift whose session expired, but it tests emptiness — diff --git a/tests/on-call-entry-store.test.ts b/tests/on-call-entry-store.test.ts index ba11f271fd..32d95c5963 100644 --- a/tests/on-call-entry-store.test.ts +++ b/tests/on-call-entry-store.test.ts @@ -78,6 +78,33 @@ describe("on-call entry cache", () => { vi.useRealTimers(); }); + it.each(["verify", "edit", "delete"])("rejects a late %s cache write after an account transition", (operation) => { + cacheOnCallEntries([contact]); + const fallback = readCachedOnCallEntries()!.entries; + const initiatingEpoch = peekOnCallEntrySessionEpoch(); + clearOnCallEntryCache(); + const nextAccount = { ...contact, id: "22222222-2222-4222-8222-222222222222", title: "Synthetic account B" }; + cacheOnCallEntries([nextAccount]); + const update = vi.fn((current: OnCallEntry[]) => (operation === "delete" ? [] : [...current, contact])); + expect(updateCachedOnCallEntries(fallback, update, initiatingEpoch)).toBe(false); + expect(update).not.toHaveBeenCalled(); + expect(readCachedOnCallEntries()?.entries).toEqual([nextAccount]); + }); + + it.each([ + { invalid: [{ id: "invalid-row" }] }, + { invalid: [null] }, + { invalid: [contact, { ...contact, id: "invalid" }] }, + ])("refuses invalid cache entries without replacing the last good list: %#", ({ invalid }) => { + cacheOnCallEntries([contact]); + expect(cacheOnCallEntries(invalid as unknown as OnCallEntry[])).toBe(false); + expect(readCachedOnCallEntries()?.entries).toEqual([contact]); + expect( + updateCachedOnCallEntries([contact], () => invalid as unknown as OnCallEntry[], peekOnCallEntrySessionEpoch()), + ).toBe(false); + expect(readCachedOnCallEntries()?.entries).toEqual([contact]); + }); + it("returns null when nothing has ever been cached", () => { expect(readCachedOnCallEntries()).toBeNull(); }); @@ -214,17 +241,19 @@ describe("on-call entry cache", () => { const second = { ...other, lastVerifiedAt: "2026-09-04T12:00:00.000Z" }; const replace = (updated: OnCallEntry) => (current: OnCallEntry[]) => current.map((existing) => (existing.id === updated.id ? updated : existing)); - expect(updateCachedOnCallEntries(snapshot, replace(first))).toBe(true); - expect(updateCachedOnCallEntries(snapshot, replace(second))).toBe(true); + expect(updateCachedOnCallEntries(snapshot, replace(first), peekOnCallEntrySessionEpoch())).toBe(true); + expect(updateCachedOnCallEntries(snapshot, replace(second), peekOnCallEntrySessionEpoch())).toBe(true); expect(readCachedOnCallEntries()?.entries).toEqual([first, second]); }); it("falls back to the screen's own list when nothing is cached", () => { - expect(updateCachedOnCallEntries([contact], (current) => current.filter(() => false))).toBe(true); + expect( + updateCachedOnCallEntries([contact], (current) => current.filter(() => false), peekOnCallEntrySessionEpoch()), + ).toBe(true); expect(readCachedOnCallEntries()?.entries).toEqual([]); clearOnCallEntryCache(); const updated = { ...contact, title: "Renamed" }; - updateCachedOnCallEntries([contact], (current) => current.map(() => updated)); + updateCachedOnCallEntries([contact], (current) => current.map(() => updated), peekOnCallEntrySessionEpoch()); expect(readCachedOnCallEntries()?.entries).toEqual([updated]); }); diff --git a/tests/on-call-mutation-response.dom.test.tsx b/tests/on-call-mutation-response.dom.test.tsx new file mode 100644 index 0000000000..25d9ba3e0b --- /dev/null +++ b/tests/on-call-mutation-response.dom.test.tsx @@ -0,0 +1,185 @@ +/** @vitest-environment jsdom */ + +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { startTransition, Suspense } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { OnCallEntryEditor, OnCallVerifyButton } from "@/components/on-call/on-call-entry-editor"; +import { cacheOnCallEntries, clearOnCallEntryCache } from "@/lib/on-call/entry-store"; +import { patchEntry } from "@/components/admin/junior/junior-shared"; +import { onCallEntryFixture } from "./helpers/on-call-entry-fixture"; + +const entry = onCallEntryFixture({ + section: "contacts", + title: "Synthetic contact", + updatedAt: "2026-11-05T12:00:00.123456+00:00", + details: { role: "Synthetic role" }, + lastVerifiedAt: null, +}); +const other = { ...entry, id: "99999999-9999-4999-8999-999999999999" }; + +afterEach(() => { + cleanup(); + clearOnCallEntryCache(); + vi.restoreAllMocks(); +}); + +describe("On Call update response identity", () => { + it("refuses a versionless legacy row before sending verification", async () => { + const request = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ entry })); + render(); + await userEvent.setup().click(screen.getByRole("button", { name: "Still correct" })); + expect(request).not.toHaveBeenCalled(); + expect(screen.getByRole("alert")).toHaveTextContent(/reload/i); + }); + + it("refuses a valid verification response for a different row", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ entry: other })); + const onVerified = vi.fn(); + render(); + await userEvent.setup().click(screen.getByRole("button", { name: "Still correct" })); + await screen.findByRole("alert"); + expect(onVerified).not.toHaveBeenCalled(); + }); + + it.each(["edited", "deleted"])("does not apply a delayed verification after its row is %s", async (change) => { + let release!: (response: Response) => void; + vi.spyOn(globalThis, "fetch").mockImplementation( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + cacheOnCallEntries([entry]); + const onVerified = vi.fn(); + render(); + await userEvent.setup().click(screen.getByRole("button", { name: "Still correct" })); + cacheOnCallEntries(change === "edited" ? [{ ...entry, title: "Newer synthetic edit" }] : []); + await act(async () => { + release(Response.json({ entry: { ...entry, lastVerifiedAt: "2026-11-05T12:00:00Z" } })); + }); + expect(onVerified).not.toHaveBeenCalled(); + expect(screen.getByRole("alert")).toHaveTextContent(/changed|removed/i); + }); + + it("accepts a delayed verification when only an unrelated row changed", async () => { + let release!: (response: Response) => void; + vi.spyOn(globalThis, "fetch").mockImplementation( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + cacheOnCallEntries([entry, other]); + const onVerified = vi.fn(); + render(); + await userEvent.setup().click(screen.getByRole("button", { name: "Still correct" })); + cacheOnCallEntries([entry, { ...other, title: "Unrelated edit" }]); + const verified = { ...entry, lastVerifiedAt: "2026-11-05T12:00:00Z" }; + await act(async () => { + release(Response.json({ entry: verified })); + }); + expect(onVerified).toHaveBeenCalledWith(verified); + expect(fetch).toHaveBeenCalledWith(`/api/on-call/entries/${entry.id}/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ expectedUpdatedAt: "2026-11-05T12:00:00.123456+00:00" }), + }); + }); + + it("accepts the visible row's verification after an uncommitted suspended render", async () => { + let release!: (response: Response) => void; + vi.spyOn(globalThis, "fetch").mockImplementation( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const suspended = new Promise(() => {}); + const attempted = vi.fn(); + function SuspendCandidate({ blocked }: { blocked: boolean }) { + if (blocked) { + attempted(); + throw suspended; + } + return null; + } + const onVerified = vi.fn(); + const content = (candidate: typeof entry, blocked: boolean) => ( + Loading candidate

}> + + +
+ ); + const view = render(content(entry, false)); + await userEvent.setup().click(screen.getByRole("button", { name: "Still correct" })); + await act(async () => { + startTransition(() => { + view.rerender(content({ ...entry, title: "Uncommitted candidate" }, true)); + }); + }); + expect(attempted).toHaveBeenCalled(); + expect(screen.queryByText("Loading candidate")).not.toBeInTheDocument(); + const verified = { ...entry, lastVerifiedAt: "2026-11-05T12:00:00Z" }; + await act(async () => { + release(Response.json({ entry: verified })); + }); + expect(onVerified).toHaveBeenCalledWith(verified); + }); + + it("refuses the previous row's delayed verification after a committed prop change", async () => { + let release!: (response: Response) => void; + vi.spyOn(globalThis, "fetch").mockImplementation( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const onVerified = vi.fn(); + const view = render(); + await userEvent.setup().click(screen.getByRole("button", { name: "Still correct" })); + view.rerender(); + await act(async () => { + release(Response.json({ entry: { ...entry, lastVerifiedAt: "2026-11-05T12:00:00Z" } })); + }); + expect(onVerified).not.toHaveBeenCalled(); + expect(screen.getByRole("alert")).toHaveTextContent(/changed/i); + }); + + it("does not deliver a delayed verification after its button unmounts", async () => { + let release!: (response: Response) => void; + vi.spyOn(globalThis, "fetch").mockImplementation( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const onVerified = vi.fn(); + const view = render(); + await userEvent.setup().click(screen.getByRole("button", { name: "Still correct" })); + view.unmount(); + await act(async () => { + release(Response.json({ entry: { ...entry, lastVerifiedAt: "2026-11-05T12:00:00Z" } })); + }); + expect(onVerified).not.toHaveBeenCalled(); + }); + + it("keeps the editor open when a PATCH returns a different valid row", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ entry: other })); + const onSaved = vi.fn(); + const onClose = vi.fn(); + const user = userEvent.setup(); + render(); + await user.type(screen.getByLabelText("Subtitle"), "Synthetic edit"); + await user.click(screen.getByTestId("on-call-entry-editor-save")); + await waitFor(() => expect(screen.getByText("Save response was invalid.")).toBeInTheDocument()); + expect(onSaved).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("refuses a common Admin PATCH response for a different valid row", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ entry: other })); + await expect(patchEntry(entry.id, {})).rejects.toThrow("Save response was invalid."); + }); +}); diff --git a/tests/on-call-repository.test.ts b/tests/on-call-repository.test.ts index d4f0a4905c..a2ac0d606d 100644 --- a/tests/on-call-repository.test.ts +++ b/tests/on-call-repository.test.ts @@ -667,6 +667,11 @@ describe("onCallEntryToRow and compliance requirements", () => { }); describe("rowToOnCallEntry", () => { + it("preserves raw PostgreSQL microseconds for conditional verification", () => { + const rawVersion = "2026-11-05T12:00:00.123456+00:00"; + expect(rowToOnCallEntry({ ...SHARED_ROW, updated_at: rawVersion }).updatedAt).toBe(rawVersion); + }); + it("drops details that do not match the section's schema instead of trusting them", () => { const entry = rowToOnCallEntry({ id: "11111111-1111-4111-8111-111111111111", diff --git a/tests/on-call-shared-entry-publisher.test.ts b/tests/on-call-shared-entry-publisher.test.ts index 19997958d7..58809dbd2a 100644 --- a/tests/on-call-shared-entry-publisher.test.ts +++ b/tests/on-call-shared-entry-publisher.test.ts @@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({ from: vi.fn(), writes: [] as Array<{ kind: string; values?: unknown; filters: Array<[string, string, unknown]> }>, storedIsPersonal: false as boolean | null, + storedUpdatedAt: "2026-11-05T12:00:00.123456+00:00", administrators: [] as string[], foreignDemoRows: [] as unknown[], })); @@ -83,6 +84,7 @@ function storedRow(isPersonal: boolean) { include_on_card: false, sort_order: 0, last_verified_at: null, + updated_at: mocks.storedUpdatedAt, }; } @@ -99,6 +101,10 @@ function fakeTable() { error: null, }); } + const versionFilter = pending.filters.find(([op, column]) => op === "eq" && column === "updated_at"); + if (pending.kind === "verify" && versionFilter && versionFilter[2] !== mocks.storedUpdatedAt) { + return Promise.resolve({ data: null, error: null }); + } const personalFilter = pending.filters.find(([op, column]) => op === "in" && column === "is_personal"); const isPersonal = pending.kind === "insert" || pending.kind === "update-entry" @@ -173,6 +179,7 @@ beforeEach(() => { mocks.from.mockImplementation(() => fakeTable()); mocks.writes.length = 0; mocks.storedIsPersonal = false; + mocks.storedUpdatedAt = "2026-11-05T12:00:00.123456+00:00"; signIn("ordinary"); }); @@ -236,10 +243,60 @@ describe("editing an On Call entry", () => { }); describe("verifying an On Call entry", () => { + it("refuses a versionless old-client verification before any write", async () => { + mocks.storedIsPersonal = true; + const response = await verifyEntry( + jsonRequest(`http://localhost/api/on-call/entries/${ENTRY_ID}/verify`, "POST"), + params, + ); + expect(response.status).toBe(428); + expect(mocks.writes).toEqual([]); + }); + + it.each([ + ["malformed timestamp", { expectedUpdatedAt: "yesterday" }, 400], + ["unknown input", { expectedUpdatedAt: "2026-11-05T12:00:00.123456+00:00", ownerId: OWNER }, 400], + ["oversized input", { expectedUpdatedAt: "x".repeat(1024) }, 413], + ])("rejects a %s before any write", async (_name, body, status) => { + mocks.storedIsPersonal = true; + const response = await verifyEntry( + jsonRequest(`http://localhost/api/on-call/entries/${ENTRY_ID}/verify`, "POST", body), + params, + ); + expect(response.status).toBe(status); + expect(mocks.writes).toEqual([]); + }); + + it("atomically refuses an intervening edit instead of verifying unseen content", async () => { + mocks.storedIsPersonal = true; + const response = await verifyEntry( + jsonRequest(`http://localhost/api/on-call/entries/${ENTRY_ID}/verify`, "POST", { + expectedUpdatedAt: "2026-11-05T11:00:00.123456+00:00", + }), + params, + ); + expect(response.status).toBe(409); + expect(mocks.writes[0]?.filters).toContainEqual(["eq", "updated_at", "2026-11-05T11:00:00.123456+00:00"]); + }); + + it("preserves the exact microsecond precondition on an accepted verification", async () => { + mocks.storedIsPersonal = true; + const response = await verifyEntry( + jsonRequest(`http://localhost/api/on-call/entries/${ENTRY_ID}/verify`, "POST", { + expectedUpdatedAt: mocks.storedUpdatedAt, + }), + params, + ); + expect(response.status).toBe(200); + expect(mocks.writes[0]?.filters).toContainEqual(["eq", "updated_at", mocks.storedUpdatedAt]); + }); + it("lets an ordinary account verify only its private entries, and says why a shared one is refused", async () => { mocks.storedIsPersonal = false; const response = await verifyEntry( - jsonRequest(`http://localhost/api/on-call/entries/${ENTRY_ID}/verify`, "POST"), + jsonRequest(`http://localhost/api/on-call/entries/${ENTRY_ID}/verify`, "POST", { + expectedUpdatedAt: mocks.storedUpdatedAt, + }), params, ); expect(response.status).toBe(403); @@ -249,7 +306,9 @@ describe("verifying an On Call entry", () => { it("verifies an ordinary account's private entry", async () => { mocks.storedIsPersonal = true; const response = await verifyEntry( - jsonRequest(`http://localhost/api/on-call/entries/${ENTRY_ID}/verify`, "POST"), + jsonRequest(`http://localhost/api/on-call/entries/${ENTRY_ID}/verify`, "POST", { + expectedUpdatedAt: mocks.storedUpdatedAt, + }), params, ); expect(response.status).toBe(200); @@ -258,7 +317,9 @@ describe("verifying an On Call entry", () => { it("still answers not found for an id the caller does not own", async () => { mocks.storedIsPersonal = null; const response = await verifyEntry( - jsonRequest(`http://localhost/api/on-call/entries/${ENTRY_ID}/verify`, "POST"), + jsonRequest(`http://localhost/api/on-call/entries/${ENTRY_ID}/verify`, "POST", { + expectedUpdatedAt: mocks.storedUpdatedAt, + }), params, ); expect(response.status).toBe(404); @@ -268,7 +329,9 @@ describe("verifying an On Call entry", () => { signIn("administrator"); mocks.storedIsPersonal = false; const response = await verifyEntry( - jsonRequest(`http://localhost/api/on-call/entries/${ENTRY_ID}/verify`, "POST"), + jsonRequest(`http://localhost/api/on-call/entries/${ENTRY_ID}/verify`, "POST", { + expectedUpdatedAt: mocks.storedUpdatedAt, + }), params, ); expect(response.status).toBe(200); diff --git a/tests/on-call-tools.dom.test.tsx b/tests/on-call-tools.dom.test.tsx index 289148f072..87e8bf038d 100644 --- a/tests/on-call-tools.dom.test.tsx +++ b/tests/on-call-tools.dom.test.tsx @@ -281,7 +281,13 @@ describe("check these", () => { it("confirms an entry and writes the updated entry to the saved copy", async () => { const user = userEvent.setup(); - const never = entry({ id: "switch", section: "contacts", title: "Switchboard", details: { role: "Switch" } }); + const never = entry({ + id: "11111111-1111-4111-8111-000000000001", + section: "contacts", + title: "Switchboard", + updatedAt: "2026-11-05T12:00:00.123456+00:00", + details: { role: "Switch" }, + }); state.entries = [never]; const updated = { ...never, lastVerifiedAt: "2026-09-25T02:00:00Z" }; const fetchMock = vi @@ -289,8 +295,12 @@ describe("check these", () => { .mockResolvedValue(new Response(JSON.stringify({ entry: updated }), { status: 200 })); render(); expect(screen.getByTestId("on-call-check-group-never")).toHaveTextContent("Switchboard"); - await user.click(screen.getByTestId("on-call-check-confirm-switch")); - expect(fetchMock).toHaveBeenCalledWith("/api/on-call/entries/switch/verify", { method: "POST" }); + await user.click(screen.getByTestId(`on-call-check-confirm-${never.id}`)); + expect(fetchMock).toHaveBeenCalledWith(`/api/on-call/entries/${never.id}/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ expectedUpdatedAt: never.updatedAt }), + }); expect(cacheOnCallEntries).toHaveBeenCalledWith([updated]); }); diff --git a/tests/on-call-verify-all.dom.test.tsx b/tests/on-call-verify-all.dom.test.tsx index 7d07bef26a..0a63304eab 100644 --- a/tests/on-call-verify-all.dom.test.tsx +++ b/tests/on-call-verify-all.dom.test.tsx @@ -1,10 +1,11 @@ /** @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 { OnCallSectionPage } from "@/components/on-call/on-call-section-page"; import { universalHeaderTrailingSlotId } from "@/lib/mode-home-composer"; +import { clearOnCallEntryCache } from "@/lib/on-call/entry-cache-keys"; import { cacheOnCallEntries } from "@/lib/on-call/entry-store"; import { type OnCallEntry } from "@/lib/on-call/entry-model"; @@ -35,13 +36,14 @@ const storeState = vi.hoisted(() => ({ vi.mock("@/lib/on-call/entry-store", () => ({ useOnCallEntries: () => storeState, cacheOnCallEntries: vi.fn(), + readCachedOnCallEntries: () => ({ entries: storeState.entries }), })); const STALE_AT = new Date("2024-01-01T00:00:00.000Z").toISOString(); function staleContact(slug: string, role: string): OnCallEntry { return { - id: slug, + id: `11111111-1111-4111-8111-${({ one: "000000000001", two: "000000000002", three: "000000000003" } as Record)[slug]}`, slug, section: "contacts", title: role, @@ -54,7 +56,8 @@ function staleContact(slug: string, role: string): OnCallEntry { includeOnCard: false, sortOrder: 0, lastVerifiedAt: STALE_AT, - } as unknown as OnCallEntry; + updatedAt: "2026-11-05T12:00:00.123456+00:00", + }; } const THREE_STALE = [ @@ -107,12 +110,136 @@ async function openMenuAndVerifyAll() { } describe("Mark all as still correct", () => { + it("refuses a versionless legacy row before starting bulk verification", async () => { + storeState.entries = [{ ...THREE_STALE[0], updatedAt: undefined }]; + render(); + await openMenuAndVerifyAll(); + expect(fetch).not.toHaveBeenCalled(); + expect(await screen.findByRole("alert")).toHaveTextContent(/reload/i); + }); + + it("keeps prior confirmations when a later bulk row has no version", async () => { + storeState.entries = [THREE_STALE[0], { ...THREE_STALE[1], updatedAt: undefined }]; + render(); + await openMenuAndVerifyAll(); + expect(await screen.findByRole("alert")).toHaveTextContent(/reload/i); + expect(fetch).toHaveBeenCalledTimes(1); + const written = vi.mocked(cacheOnCallEntries).mock.calls.at(-1)?.[0]; + expect(written?.[0].lastVerifiedAt).toBe("2026-09-13T00:00:00.000Z"); + expect(written?.[1]).toEqual(storeState.entries[1]); + }); + + it("stops before the next bulk request when the account changes", async () => { + let release!: (response: Response) => void; + vi.stubGlobal( + "fetch", + vi.fn( + () => + new Promise((resolve) => { + release = resolve; + }), + ), + ); + render(); + await openMenuAndVerifyAll(); + expect(fetch).toHaveBeenCalledTimes(1); + clearOnCallEntryCache(); + await act(async () => { + release(new Response(JSON.stringify({ entry: THREE_STALE[0] }), { status: 200 })); + }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(cacheOnCallEntries).not.toHaveBeenCalled(); + }); + + it.each([ + ["missing entry", {}], + ["incomplete entry", { entry: { id: "one" } }], + ["wrong returned id", { entry: { ...THREE_STALE[0], id: "99999999-9999-4999-8999-999999999999" } }], + ])("stops and reports a successful response with %s", async (_name, payload) => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify(payload), { status: 200 })), + ); + render(); + await openMenuAndVerifyAll(); + await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent(/Bed management/)); + expect(fetch).toHaveBeenCalledTimes(1); + expect(cacheOnCallEntries).not.toHaveBeenCalled(); + }); + + it("keeps the first durable confirmation when the next successful response is invalid", async () => { + const confirmed = { ...THREE_STALE[0], lastVerifiedAt: "2026-11-05T00:00:00.000Z" }; + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ entry: confirmed }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({}), { status: 200 })), + ); + render(); + await openMenuAndVerifyAll(); + await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent(/Switchboard/)); + expect(fetch).toHaveBeenCalledTimes(2); + const lastWrite = vi.mocked(cacheOnCallEntries).mock.calls.at(-1)?.[0]; + expect(lastWrite).toEqual([confirmed, THREE_STALE[1], THREE_STALE[2]]); + }); + + it("preserves an intervening edit when delayed bulk confirmations commit", async () => { + let release!: (response: Response) => void; + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce(Response.json({ entry: { ...THREE_STALE[0], lastVerifiedAt: "2026-11-05T12:00:00Z" } })) + .mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ) + .mockResolvedValueOnce(Response.json({ entry: { ...THREE_STALE[2], lastVerifiedAt: "2026-11-05T12:00:00Z" } })), + ); + render(); + await openMenuAndVerifyAll(); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + const edited = { + ...THREE_STALE[0], + title: "Newer synthetic edit", + details: { role: "Newer synthetic edit", phone: "6200" }, + }; + storeState.entries = [edited, THREE_STALE[1], THREE_STALE[2]]; + await act(async () => { + release(Response.json({ entry: { ...THREE_STALE[1], lastVerifiedAt: "2026-11-05T12:00:00Z" } })); + }); + await waitFor(() => expect(cacheOnCallEntries).toHaveBeenCalled()); + expect(vi.mocked(cacheOnCallEntries).mock.calls.at(-1)?.[0][0]).toEqual(edited); + expect(screen.getByRole("alert")).toHaveTextContent(/changed|removed/i); + }); + + it.each([409, 428])( + "explains reload recovery when verification refuses a stale or missing version (%s)", + async (status) => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("{}", { status })), + ); + render(); + await openMenuAndVerifyAll(); + expect(await screen.findByRole("alert")).toHaveTextContent(/Reload and review the latest entry/i); + expect(cacheOnCallEntries).not.toHaveBeenCalled(); + }, + ); + it("confirms every overdue entry in the view", async () => { render(); await openMenuAndVerifyAll(); await waitFor(() => expect(vi.mocked(fetch)).toHaveBeenCalledTimes(3)); for (const entry of THREE_STALE) { - expect(vi.mocked(fetch)).toHaveBeenCalledWith(`/api/on-call/entries/${entry.id}/verify`, { method: "POST" }); + expect(vi.mocked(fetch)).toHaveBeenCalledWith(`/api/on-call/entries/${entry.id}/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ expectedUpdatedAt: entry.updatedAt }), + }); } }); @@ -137,7 +264,8 @@ describe("Mark all as still correct", () => { vi.stubGlobal( "fetch", vi.fn(async (url: string) => { - if (String(url).includes("/two/")) return { ok: false, json: async () => ({}) } as unknown as Response; + if (String(url).includes(`/api/on-call/entries/${THREE_STALE[1].id}/`)) + return { ok: false, json: async () => ({}) } as unknown as Response; const id = String(url).split("/").at(-2); return { ok: true, @@ -156,18 +284,22 @@ describe("Mark all as still correct", () => { // The one that did succeed before the failure is still recorded: a partial // run must not be rolled back into "nothing happened". const lastWrite = vi.mocked(cacheOnCallEntries).mock.calls.at(-1)?.[0] ?? []; - expect(lastWrite.find((entry) => entry.id === "one")?.lastVerifiedAt).toBe("2026-09-13T00:00:00.000Z"); - expect(lastWrite.find((entry) => entry.id === "three")?.lastVerifiedAt).toBe(STALE_AT); + expect(lastWrite.find((entry) => entry.slug === "one")?.lastVerifiedAt).toBe("2026-09-13T00:00:00.000Z"); + expect(lastWrite.find((entry) => entry.slug === "three")?.lastVerifiedAt).toBe(STALE_AT); }); // Regression, 2026-09-24: shared rows from other accounts were included, // the server refused them, and the loop stopped at the first one. it("confirms only the reader's own overdue rows, never another account's", async () => { - storeState.entries = THREE_STALE.map((entry) => (entry.id === "two" ? { ...entry, isOwn: false } : entry)); + storeState.entries = THREE_STALE.map((entry) => (entry.slug === "two" ? { ...entry, isOwn: false } : entry)); render(); await openMenuAndVerifyAll(); await waitFor(() => expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2)); - expect(vi.mocked(fetch)).not.toHaveBeenCalledWith("/api/on-call/entries/two/verify", { method: "POST" }); + expect(vi.mocked(fetch)).not.toHaveBeenCalledWith(`/api/on-call/entries/${THREE_STALE[1].id}/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ expectedUpdatedAt: THREE_STALE[1].updatedAt }), + }); expect(screen.getAllByRole("button", { name: /^Edit / }).length).toBeGreaterThan(0); }); diff --git a/tests/production-readiness-offline.test.ts b/tests/production-readiness-offline.test.ts index 01e2b497ca..a543d1fd9b 100644 --- a/tests/production-readiness-offline.test.ts +++ b/tests/production-readiness-offline.test.ts @@ -298,7 +298,7 @@ describe("production readiness provider policy", () => { ).toBe(false); }); - it("reports the provider capability gap before generic CI readiness", () => { + it.each([undefined, ""])("reports provider capability gaps with offline URLs %s", (offlineUrl) => { const environment = { ...process.env }; for (const name of providerEnvironmentKeys) delete environment[name]; Object.assign(environment, { @@ -308,6 +308,10 @@ describe("production readiness provider policy", () => { NEXT_PUBLIC_DEMO_MODE: "true", PLAYWRIGHT_OFFLINE_MODE: "true", }); + if (offlineUrl !== undefined) { + environment.NEXT_PUBLIC_SUPABASE_URL = offlineUrl; + environment.SUPABASE_DB_URL = offlineUrl; + } const result = spawnSync(process.execPath, ["scripts/run-tsx.mjs", "scripts/production-readiness.ts", "--ci"], { cwd: path.resolve(import.meta.dirname, ".."), encoding: "utf8", diff --git a/tests/registry-governance-reconciliation.test.ts b/tests/registry-governance-reconciliation.test.ts index 589f759171..de221364f1 100644 --- a/tests/registry-governance-reconciliation.test.ts +++ b/tests/registry-governance-reconciliation.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { assertExpectedRegistryProjectRef, + applyRegistryGovernancePlan, buildRegistryGovernancePlan, type RegistryGovernanceDocument, type RegistryGovernanceLabel, @@ -150,6 +151,34 @@ describe("registry governance reconciliation", () => { expect(second.labelIdsToDelete).toEqual([]); }); + it("preserves existing generated labels when registry replacement fails", async () => { + const projections = (["service", "form", "medication", "differential"] as RegistryCorpusKind[]).map(projection); + const documents = projections.map((value) => ({ + ...documentFor(value), + metadata: value.requiredMetadata, + updated_at: "snapshot-A", + })); + let rows = projections.map((value) => + label(value, value.kind === "service" ? { id: "old-service-intent", label: "old intent" } : {}), + ); + const before = structuredClone(rows); + const plan = buildRegistryGovernancePlan({ projections, documents, labels: rows, expectedCounts }); + const supabase = { + rpc: async () => ({ data: null, error: { message: "synthetic atomic replacement failure" } }), + from: () => ({ + delete: () => ({ + in: async (_column: string, ids: string[]) => { + rows = rows.filter((row) => !ids.includes(row.id)); + return { error: null }; + }, + }), + upsert: async () => ({ error: { message: "synthetic replacement failure" } }), + }), + }; + await expect(applyRegistryGovernancePlan(supabase as never, plan)).rejects.toThrow(); + expect(rows).toEqual(before); + }); + it("aborts before writing on missing, duplicate, or owner-mismatched projections", () => { const projections = (["service", "form", "medication", "differential"] as RegistryCorpusKind[]).map(projection); const documents = projections.map(documentFor); diff --git a/tests/roster-calendar-link.test.ts b/tests/roster-calendar-link.test.ts index 608cff0ba9..6275a02172 100644 --- a/tests/roster-calendar-link.test.ts +++ b/tests/roster-calendar-link.test.ts @@ -1,10 +1,11 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { fetchCalendarLink, normaliseCalendarLink, pinnedLookup, type LinkRequest, + type LinkResolver, } from "@/lib/roster/calendar-link-fetch"; vi.mock("server-only", () => ({})); @@ -14,6 +15,8 @@ vi.mock("server-only", () => ({})); const PUBLIC = async () => [{ address: "93.184.216.34", family: 4 as const }]; const ICS = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"; +afterEach(() => vi.useRealTimers()); + function reply(status: number, body = "", location: string | null = null): ReturnType { return Promise.resolve({ status, @@ -58,6 +61,40 @@ describe("pinnedLookup", () => { }); describe("fetchCalendarLink", () => { + it.each(["resolve", "reject"])( + "times out stalled DNS and owns its late %s without dispatching HTTP", + async (outcome) => { + vi.useFakeTimers(); + let release!: () => void; + const resolve: LinkResolver = () => + new Promise((resolve, reject) => { + release = () => + outcome === "resolve" + ? resolve([{ address: "93.184.216.34", family: 4 }]) + : reject(new Error("synthetic resolver failure")); + }); + const request = vi.fn(async ({ signal }) => { + signal.throwIfAborted(); + return reply(200, ICS); + }); + let failure: unknown; + const pending = fetchCalendarLink("https://roster.example.org/a.ics", { resolve, request }).catch( + (error: unknown) => { + failure = error; + }, + ); + try { + await vi.advanceTimersByTimeAsync(10_000); + expect(failure).toMatchObject({ reason: "timeout" }); + expect(request).not.toHaveBeenCalled(); + } finally { + release(); + await pending; + } + expect(request).not.toHaveBeenCalled(); + }, + ); + it("returns a calendar from a public address, pinned to the checked address", async () => { const request = vi.fn(() => reply(200, ICS)); await expect(fetchCalendarLink("https://roster.example.org/a.ics", { resolve: PUBLIC, request })).resolves.toBe( diff --git a/tests/run-next-build.test.ts b/tests/run-next-build.test.ts index 3aaa4a1953..2cea102a9d 100644 --- a/tests/run-next-build.test.ts +++ b/tests/run-next-build.test.ts @@ -1,7 +1,11 @@ +import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import path from "node:path"; +import { runInNewContext } from "node:vm"; +import ts from "typescript"; import { describe, expect, it } from "vitest"; import { DEFAULT_NODE_OPTIONS, nextBuildArguments, nextBuildEnvironment } from "../scripts/run-next-build.mjs"; +import { offlineTestEnvironment } from "../scripts/test-environment.mjs"; const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { scripts: Record; @@ -37,3 +41,86 @@ describe("cross-platform next build step (#NM9S1V)", () => { expect(build).not.toContain("${"); }); }); + +/** Evaluate only the runner's real environment initializer: importing its module would start a build. */ +function playwrightBuildEnvironment( + environment: Record, +): Record & { NODE_ENV: "production" } { + const source = readFileSync(new URL("../scripts/run-playwright.mjs", import.meta.url), "utf8"); + const parsed = ts.createSourceFile("run-playwright.mjs", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS); + let initializer: string | undefined; + function visit(node: ts.Node) { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.name.text === "offlineEnv" && + node.initializer + ) { + initializer = node.initializer.getText(parsed); + } + ts.forEachChild(node, visit); + } + visit(parsed); + if (!initializer) throw new Error("Playwright build environment initializer was not found."); + const result = runInNewContext( + initializer, + { + offlineTestEnvironment, + nextBuildEnvironment, + lock: { environment }, + port: 3131, + baseUrl: "http://127.0.0.1:3131", + relativeDistDir: ".next-playwright/synthetic-heap/dist", + relativeTsConfigPath: ".next-playwright/synthetic-heap/tsconfig.json", + mockupProjectRequested: false, + }, + { timeout: 1000 }, + ) as Record; + if (result.NODE_ENV !== "production") throw new Error("Expected the runner's production environment."); + return { ...result, NODE_ENV: result.NODE_ENV }; +} + +describe("Playwright build child-process heap", () => { + it.each<{ + name: string; + input: Record; + expectedOptions: string; + heapMiB: number; + }>([ + { name: "unset", input: {}, expectedOptions: "--max-old-space-size=8192", heapMiB: 8192 }, + { name: "blank", input: { NODE_OPTIONS: "" }, expectedOptions: "--max-old-space-size=8192", heapMiB: 8192 }, + { + name: "explicit caller budget", + input: { NODE_OPTIONS: "--max-old-space-size=6144" }, + expectedOptions: "--max-old-space-size=6144", + heapMiB: 6144, + }, + ])( + "propagates $name heap settings to a real child without provider access", + ({ input, expectedOptions, heapMiB }) => { + const environment = playwrightBuildEnvironment({ + ...input, + OPENAI_API_KEY: "synthetic-provider-sentinel", + SUPABASE_SERVICE_ROLE_KEY: "synthetic-service-sentinel", + KEEP_SYNTHETIC_VALUE: "kept", + }); + const child = spawnSync( + process.execPath, + ["-e", 'console.log(JSON.stringify({heap:require("node:v8").getHeapStatistics().heap_size_limit}))'], + { env: environment, encoding: "utf8", timeout: 10000 }, + ); + expect(child.error).toBeUndefined(); + expect(child.status, child.stderr).toBe(0); + const observed = JSON.parse(child.stdout) as { heap: number }; + expect(observed.heap).toBeGreaterThanOrEqual(heapMiB * 1024 ** 2); + expect(observed.heap).toBeLessThan((heapMiB + 256) * 1024 ** 2); + expect(environment.NODE_OPTIONS).toBe(expectedOptions); + expect(environment.KEEP_SYNTHETIC_VALUE).toBe("kept"); + expect(environment.OPENAI_API_KEY).toBe(""); + expect(environment.SUPABASE_SERVICE_ROLE_KEY).toBe(""); + expect(environment.NEXT_PUBLIC_SUPABASE_URL).toBe("http://127.0.0.1:1"); + expect(environment.PLAYWRIGHT_OFFLINE_MODE).toBe("true"); + expect(environment.NEXT_DIST_DIR).toBe(".next-playwright/synthetic-heap/dist"); + }, + ); +}); diff --git a/tests/sql/ingestion-maintenance-deep-memory.sql b/tests/sql/ingestion-maintenance-deep-memory.sql new file mode 100644 index 0000000000..6265bec959 --- /dev/null +++ b/tests/sql/ingestion-maintenance-deep-memory.sql @@ -0,0 +1,55 @@ +-- Disposable PostgreSQL only; synthetic local-producer artifacts, all changes roll back. +\set ON_ERROR_STOP on +begin; +set local statement_timeout='60s'; +set local request.jwt.claim.role='service_role'; +set local request.jwt.claims='{"role":"service_role"}'; +create function pg_temp.assert_true(ok boolean,message text) returns void language plpgsql as $$ begin + if ok is distinct from true then raise exception '%',message; end if; +end $$; +select pg_temp.assert_true(not exists(select 1 from public.documents),'requires empty disposable database'); +insert into auth.users(id) values('10000000-0000-4000-8000-000000000001'); +insert into public.documents(id,owner_id,title,file_name,file_type,storage_path,status,metadata) +values('30000000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000001','Synthetic memory','synthetic.txt','text/plain','synthetic/memory.txt','indexed', +'{"index_generation_id":"40000000-0000-4000-8000-000000000001","worker_key":"retained"}'); +-- Old current A, rollback B and uniquely staged C have independent producer generations. +insert into public.document_sections(id,document_id,owner_id,section_index,heading,producer,artifact_generation_id,index_generation_id,metadata) +select ('50000000-0000-4000-8000-00000000000'||n)::uuid,'30000000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000001',0,'Synthetic section','local-worker', +('60000000-0000-4000-8000-00000000000'||n)::uuid, +case when n=3 then '60000000-0000-4000-8000-000000000003'::uuid else ('40000000-0000-4000-8000-00000000000'||n)::uuid end, +jsonb_build_object('generated_by','local-worker','artifact_generation_id','60000000-0000-4000-8000-00000000000'||n, +'index_generation_id',case when n=3 then '60000000-0000-4000-8000-000000000003' else '40000000-0000-4000-8000-00000000000'||n end) +from generate_series(1,3) n; +insert into public.document_memory_cards(document_id,owner_id,section_id,card_type,title,content,producer,artifact_generation_id,index_generation_id,metadata,embedding) +select document_id,owner_id,id,'section_summary','Synthetic card','Synthetic memory evidence',producer,artifact_generation_id,index_generation_id,metadata, +array_fill(0::real,array[1536])::extensions.vector from public.document_sections; + +do $$ declare stamp timestamptz; result jsonb; begin + select updated_at into stamp from public.documents; + begin + perform public.commit_document_maintenance_deep_memory('30000000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000001',stamp-interval '1 second', + '40000000-0000-4000-8000-000000000001','[]','[]','local-worker','60000000-0000-4000-8000-000000000003','rag-deep-memory-v2','synthetic-version',1,1,'{}',0); + raise exception 'expected stale memory snapshot conflict'; + exception when serialization_failure then null; end; + perform pg_temp.assert_true((select count(*)=3 from public.document_sections),'rejected stale memory preserves all artifacts'); + insert into public.document_sections(id,document_id,owner_id,section_index,heading,metadata) + values('50000000-0000-4000-8000-000000000004','30000000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000001',99,'Unassigned synthetic legacy section','{"generated_by":"local-worker"}'); + begin + perform public.commit_document_maintenance_deep_memory('30000000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000001',stamp, + '40000000-0000-4000-8000-000000000001','[]','[]','local-worker','60000000-0000-4000-8000-000000000003','rag-deep-memory-v2','synthetic-version',1,1,'{}',0); + raise exception 'expected unassigned lineage refusal'; + exception when check_violation then + if sqlerrm<>'legacy_generation_lineage_requires_fenced_reindex' then raise; end if; + end; + perform pg_temp.assert_true(exists(select 1 from public.document_sections where id='50000000-0000-4000-8000-000000000004'),'unassigned lineage refusal preserves legacy evidence'); + delete from public.document_sections where id='50000000-0000-4000-8000-000000000004'; + result:=public.commit_document_maintenance_deep_memory('30000000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000001',stamp, + '40000000-0000-4000-8000-000000000001','[]','[]','local-worker','60000000-0000-4000-8000-000000000003','rag-deep-memory-v2','synthetic-version',1,1,'{}',0); + perform pg_temp.assert_true(result->>'updated_at' is not null and result->'metadata'->>'worker_key'='retained','commit returns own new snapshot and preserves worker metadata'); +end $$; +select pg_temp.assert_true(not exists(select 1 from public.document_sections where id='50000000-0000-4000-8000-000000000001'),'superseded current memory replaced'); +select pg_temp.assert_true(exists(select 1 from public.document_sections where id='50000000-0000-4000-8000-000000000002') and +exists(select 1 from public.document_memory_cards where section_id='50000000-0000-4000-8000-000000000002'),'rollback sections and cards preserved'); +select pg_temp.assert_true(exists(select 1 from public.document_sections where id='50000000-0000-4000-8000-000000000003' and index_generation_id='40000000-0000-4000-8000-000000000001'),'prepared generation promoted only into expected current index generation'); +select pg_temp.assert_true((select metadata->>'index_generation_id'='40000000-0000-4000-8000-000000000001' from public.documents),'committed source generation pointer unchanged'); +rollback; diff --git a/tests/sql/ingestion-maintenance.sql b/tests/sql/ingestion-maintenance.sql new file mode 100644 index 0000000000..cab032cb5f --- /dev/null +++ b/tests/sql/ingestion-maintenance.sql @@ -0,0 +1,137 @@ +-- Disposable PostgreSQL only. All examples are synthetic, and every change rolls back. +\set ON_ERROR_STOP on +begin; +set local statement_timeout = '60s'; +set local request.jwt.claim.role = 'service_role'; +set local request.jwt.claims = '{"role":"service_role"}'; +create function pg_temp.assert_true(ok boolean, message text) returns void language plpgsql as $$ begin + if ok is distinct from true then raise exception '%',message; end if; +end $$; +select pg_temp.assert_true(not exists(select 1 from public.documents), 'requires empty disposable database'); +insert into auth.users(id) values ('10000000-0000-4000-8000-000000000001'); +insert into public.import_batches(id,owner_id,name,source_root,include_glob,status) +values('20000000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000001','Synthetic maintenance','synthetic','*.txt','processing'); +insert into public.documents(id,owner_id,title,file_name,file_type,file_size,storage_path,content_hash,status,page_count,chunk_count,image_count,metadata) +values('30000000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000001','Synthetic maintenance','synthetic.txt','text/plain',9, +'synthetic/maintenance.txt','synthetic-hash','indexed',3,4,2,'{"index_generation_id":"40000000-0000-4000-8000-000000000001","worker_key":"preserve"}'); +create temp table original_snapshot as select updated_at,metadata from public.documents; + +-- Definite queue insertion failure must roll back every queue-state write and retain A. +create function pg_temp.fail_job_insert() returns trigger language plpgsql as $$ begin + if current_setting('ingestion_maintenance.fail_job',true)='yes' then raise exception 'synthetic_job_failure'; end if; + return new; +end $$; +create trigger synthetic_fail_job before insert on public.ingestion_jobs for each row execute function pg_temp.fail_job_insert(); +set local ingestion_maintenance.fail_job='yes'; +do $$ begin + begin + perform public.enqueue_import_document('30000000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000001','synthetic-hash','20000000-0000-4000-8000-000000000001',true,3); + raise exception 'expected synthetic job failure'; + exception when raise_exception then if sqlerrm<>'synthetic_job_failure' then raise; end if; end; +end $$; +select pg_temp.assert_true((select status='indexed' and page_count=3 and chunk_count=4 and image_count=2 and metadata=(select metadata from original_snapshot) from public.documents), 'failed force preserves generation A and counts'); +set local ingestion_maintenance.fail_job='no'; +select pg_temp.assert_true(public.enqueue_import_document('30000000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000001','synthetic-hash','20000000-0000-4000-8000-000000000001',true,3)->>'outcome'='enqueued', 'force queues replacement'); +select pg_temp.assert_true(public.enqueue_import_document('30000000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000001','synthetic-hash','20000000-0000-4000-8000-000000000001',true,3)->>'outcome'='already_active', 'duplicate force converges on existing job'); +select pg_temp.assert_true((select count(*)=1 from public.ingestion_jobs), 'only one open job'); +select pg_temp.assert_true((select status='indexed' and page_count=3 and metadata->>'worker_key'='preserve' from public.documents), 'successful enqueue also preserves A'); +delete from public.ingestion_jobs; + +-- Failed replacement restores generated labels and preserves manual labels. +insert into public.document_labels(document_id,owner_id,label,label_type,source) values +('30000000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000001','old generated','topic','generated'), +('30000000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000001','manual retained','topic','manual'); +do $$ declare stamp timestamptz; begin + select updated_at into stamp from public.documents; + begin + perform public.replace_document_maintenance_artifacts('30000000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000001',stamp,'40000000-0000-4000-8000-000000000001','labels', + '{"labels":[{"label":"invalid replacement","label_type":"invalid","confidence":0.8,"metadata":{}}]}'); + raise exception 'expected label constraint failure'; + exception when check_violation then null; end; +end $$; +select pg_temp.assert_true((select count(*)=2 from public.document_labels) and exists(select 1 from public.document_labels where label='old generated'), 'failed label insert restores both previous sets'); + +-- Owner, generation and even same-generation version conflicts reject without replacement. +do $$ declare stamp timestamptz; generation uuid; owner_id uuid; begin + select updated_at into stamp from public.documents; + for generation,owner_id in select * from (values + ('40000000-0000-4000-8000-000000000002'::uuid,'10000000-0000-4000-8000-000000000001'::uuid), + ('40000000-0000-4000-8000-000000000001'::uuid,'10000000-0000-4000-8000-000000000002'::uuid)) cases loop + begin + perform public.replace_document_maintenance_artifacts('30000000-0000-4000-8000-000000000001',owner_id,stamp,generation,'labels','{"labels":[]}'); + raise exception 'expected identity conflict'; + exception when serialization_failure then null; end; + end loop; + begin + perform public.replace_document_maintenance_artifacts('30000000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000001',stamp-interval '1 second','40000000-0000-4000-8000-000000000001','labels','{"labels":[]}'); + raise exception 'expected version conflict'; + exception when serialization_failure then null; end; +end $$; +select pg_temp.assert_true((select count(*)=2 from public.document_labels), 'all rejected identities preserve labels'); + +-- A visual insert failure restores current units and leaves rollback-generation units intact. +insert into public.document_images(id,document_id,storage_path,index_generation_id) +values('50000000-0000-4000-8000-000000000001','30000000-0000-4000-8000-000000000001','synthetic/image.png','40000000-0000-4000-8000-000000000001'); +insert into public.document_index_units(document_id,owner_id,unit_type,source_image_id,title,content,index_generation_id,embedding) +select '30000000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000001','visual_summary','50000000-0000-4000-8000-000000000001','Synthetic visual','Synthetic visual evidence',generation,array_fill(0::real,array[1536])::extensions.vector +from unnest(array['40000000-0000-4000-8000-000000000001','40000000-0000-4000-8000-000000000002']::uuid[]) generation; +do $$ declare stamp timestamptz; begin + select updated_at into stamp from public.documents; + begin + perform public.replace_document_maintenance_artifacts('30000000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000001',stamp,'40000000-0000-4000-8000-000000000001','visual', + '{"image_ids":["50000000-0000-4000-8000-000000000001"],"units":[{"document_id":"30000000-0000-4000-8000-000000000001","owner_id":"10000000-0000-4000-8000-000000000001","index_generation_id":"40000000-0000-4000-8000-000000000001","unit_type":"visual_summary","source_image_id":"50000000-0000-4000-8000-000000000001","title":"Replacement","content":"Synthetic replacement"}],"image_patches":[]}'); + raise exception 'expected embedding constraint failure'; + exception when not_null_violation then null; end; +end $$; +select pg_temp.assert_true((select count(*)=2 from public.document_index_units), 'failed visual replacement preserves current and rollback generations'); +-- An image id outside this document and generation must be refused, not silently accepted. +do $$ declare stamp timestamptz; begin + select updated_at into stamp from public.documents; + begin + perform public.replace_document_maintenance_artifacts('30000000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000001',stamp,'40000000-0000-4000-8000-000000000001','visual', + '{"image_ids":["50000000-0000-4000-8000-0000000000ff"],"units":[],"image_patches":[]}'); + raise exception 'expected unknown image id to be refused'; + exception when serialization_failure then null; end; +end $$; +select pg_temp.assert_true((select count(*)=2 from public.document_index_units), 'refused unknown image id preserves current and rollback generations'); +select pg_temp.assert_true(to_regprocedure('public.reset_document_index(uuid)') is null, 'generation-blind reset is absent'); +-- Test the actual app role, not only JWT claims or a revoked grant. A retained +-- destructive function would raise insufficient_privilege instead and fail. +set local role service_role; +do $$ begin + begin + execute 'select public.reset_document_index($1::uuid)' using '30000000-0000-4000-8000-000000000001'::uuid; + raise exception 'retired destructive reset unexpectedly executed'; + exception when undefined_function then null; end; +end $$; +reset role; +select pg_temp.assert_true((select count(*)=2 from public.document_index_units), 'retired reset call preserves current and rollback generations'); +select pg_temp.assert_true(has_function_privilege('service_role','public.reset_ingestion_attempt_artifacts(jsonb,uuid)','execute'), 'fenced worker reset remains executable'); +select pg_temp.assert_true(not has_function_privilege('authenticated','public.reset_ingestion_attempt_artifacts(jsonb,uuid)','execute') + and not has_function_privilege('anon','public.reset_ingestion_attempt_artifacts(jsonb,uuid)','execute'), 'fenced worker reset remains service-only'); +select pg_temp.assert_true(has_function_privilege('service_role','public.commit_document_deep_memory_generation(jsonb,uuid,text,text,integer,integer,jsonb,jsonb,jsonb)','execute'), 'fenced worker deep-memory commit remains executable'); +select pg_temp.assert_true(to_regprocedure('public.commit_document_deep_memory_generation(uuid,text,uuid,text,text,integer,integer,jsonb,integer)') is not null, 'separate legacy deep-memory definition is retained'); +select pg_temp.assert_true(not has_function_privilege('authenticated','public.replace_document_maintenance_artifacts(uuid,uuid,timestamptz,uuid,text,jsonb)','execute'), 'maintenance is service-only'); + +-- Registry reconciliation keeps targeted label deletion/upsert and metadata in this same snapshot. +insert into public.documents(id,owner_id,title,file_name,file_type,storage_path,source_path,status,metadata) +values('30000000-0000-4000-8000-000000000002','10000000-0000-4000-8000-000000000001','Synthetic registry','service-synthetic.registry.json', +'application/vnd.clinical-kb.registry+json','registry://service/synthetic-service','registry://service/synthetic-service','indexed','{"manual_metadata":"retained"}'); +insert into public.document_labels(id,document_id,owner_id,label,label_type,source) +values('70000000-0000-4000-8000-000000000001','30000000-0000-4000-8000-000000000002','10000000-0000-4000-8000-000000000001','old site','site','generated'); +create function pg_temp.fail_registry_label_insert() returns trigger language plpgsql as $$ begin + if new.document_id='30000000-0000-4000-8000-000000000002' then raise exception 'synthetic_registry_label_failure'; end if; + return new; +end $$; +create trigger synthetic_fail_registry_label before insert on public.document_labels for each row execute function pg_temp.fail_registry_label_insert(); +do $$ declare stamp timestamptz; begin + select updated_at into stamp from public.documents where id='30000000-0000-4000-8000-000000000002'; + begin + perform public.replace_document_maintenance_artifacts('30000000-0000-4000-8000-000000000002','10000000-0000-4000-8000-000000000001',stamp,null,'registry', + '{"metadata_patch":{"source_kind":"registry_record","registry_record_kind":"service","registry_record_id":"synthetic-service","registry_record_slug":"synthetic-service"},"label_ids_to_delete":["70000000-0000-4000-8000-000000000001"],"labels_to_upsert":[{"label":"operational-process","label_type":"document_intent","confidence":1,"metadata":{}}]}'); + raise exception 'expected registry insertion failure'; + exception when raise_exception then if sqlerrm<>'synthetic_registry_label_failure' then raise; end if; end; +end $$; +select pg_temp.assert_true(exists(select 1 from public.document_labels where id='70000000-0000-4000-8000-000000000001'),'failed registry upsert restores deleted generated label'); +select pg_temp.assert_true((select metadata='{"manual_metadata":"retained"}'::jsonb from public.documents where id='30000000-0000-4000-8000-000000000002'),'failed registry operation preserves metadata'); +rollback; diff --git a/tests/sql/private-ingestion-document-deletion.sql b/tests/sql/private-ingestion-document-deletion.sql new file mode 100644 index 0000000000..dd0b3270df --- /dev/null +++ b/tests/sql/private-ingestion-document-deletion.sql @@ -0,0 +1,238 @@ +-- Disposable PostgreSQL only. Registers synthetic originals through the real fenced API. +-- Extra synthetic derived rows exercise every private evidence table; all changes roll back. +\set ON_ERROR_STOP on +begin; +set local statement_timeout = '60s'; + +create function pg_temp.assert_true(ok boolean, message text) returns void +language plpgsql as $$ begin + if ok is distinct from true then raise exception '%', message; end if; +end $$; +select pg_temp.assert_true(not has_function_privilege('service_role', + 'ingestion_generation_evidence.purge_deleted_document()', 'EXECUTE'), 'purge helper is not a service RPC'); +select pg_temp.assert_true(not has_schema_privilege('authenticated', 'ingestion_generation_evidence', 'USAGE') + and not has_schema_privilege('service_role', 'ingestion_generation_evidence', 'USAGE'), + 'private evidence schema grants remain closed'); + +insert into auth.users(id, email) values + ('da000000-0000-4000-8000-000000000001', 'synthetic-removal@example.org'), + ('da000000-0000-4000-8000-000000000002', 'synthetic-retained@example.org'); +insert into public.documents(id, owner_id, title, file_name, file_type, file_size, content_hash, storage_path, status) +select ('db000000-0000-4000-8000-00000000000' || n)::uuid, + ('da000000-0000-4000-8000-00000000000' || n)::uuid, + 'Synthetic retention fixture', 'synthetic.txt', 'text/plain', 9, + encode(extensions.digest(convert_to('SYNTHETIC', 'UTF8'), 'sha256'), 'hex'), + 'synthetic/removal/' || n || '.txt', 'queued' +from generate_series(1, 2) n; +insert into public.ingestion_jobs(document_id, status) +select id, 'pending' from public.documents where id::text like 'db000000-%'; +insert into public.audit_logs(id, owner_id, action, resource_type, resource_id, metadata) +values ('dc000000-0000-4000-8000-000000000001', 'da000000-0000-4000-8000-000000000001', + 'synthetic.document.access', 'document', 'db000000-0000-4000-8000-000000000001', '{}'); + +create temp table retention_claims as +select job from public.claim_ingestion_jobs('synthetic-retention-worker', 2, 45, + array['db000000-0000-4000-8000-000000000001', 'db000000-0000-4000-8000-000000000002']::uuid[], + array['text/plain']) job; +select pg_temp.assert_true((select count(*) = 2 from retention_claims), 'both synthetic claims are fenced'); + +do $register$ +declare r record; fence jsonb; expectation jsonb; expectation_bytes bytea; units jsonb; declarations jsonb; result jsonb; +begin + for r in select job from retention_claims loop + fence := jsonb_build_object('job_kind', 'node', 'job_id', r.job->'id', + 'document_id', r.job->'document_id', 'worker_id', r.job->'locked_by', + 'lease_token', r.job->'lease_token', 'expected_generation_id', r.job->'lease_expected_generation_id', + 'authority_digest', r.job->'lease_authority_digest'); + expectation := jsonb_build_object('version', 1, 'sourceSha256', 'sha256:' || (r.job->'documents'->>'content_hash'), + 'tag', 'unavailable', 'units', null, 'witness', null, 'limitation', 'synthetic source inventory unavailable'); + expectation_bytes := convert_to(expectation::text, 'UTF8'); + select jsonb_agg(jsonb_build_object('id', jsonb_build_object('family', 'private_objects', 'key', u.key), + 'groupKey', 'private_objects', 'required', true, 'dependencies', '[]'::jsonb, + 'sourceEvidenceIds', '[]'::jsonb, 'qualification', 'source_bytes_exact', 'representation', u.representation) + order by u.key) into units from (values + ('original-source', 'original_binary'), ('page:1:extracted', 'extracted_utf8'), + ('page:1:persisted', 'persisted_utf8'), ('page:1:original-provenance', 'provenance_json'), + ('page:1:persisted-provenance', 'provenance_json')) u(key, representation); + declarations := jsonb_build_object('version', 1, 'units', units, 'groups', jsonb_build_array( + jsonb_build_object('family', 'private_objects', 'groupKey', 'private_objects', 'declaration', + jsonb_build_object('producer', jsonb_build_object('name', 'synthetic', 'version', '1', + 'outputManifestDigest', 'sha256:' || repeat('a', 64)), 'artifactGenerationId', null, + 'population', jsonb_build_object('tag', 'known', 'reason', null, + 'expectedUnits', (select jsonb_agg(u.value->'id') from jsonb_array_elements(units) u), + 'basis', jsonb_build_array(jsonb_build_object('reference', 'synthetic-original', + 'sha256', 'sha256:' || (r.job->'documents'->>'content_hash')))))))); + result := public.begin_ingestion_generation_evidence(fence, (r.job->>'lease_write_generation_id')::uuid, + jsonb_build_object('version', 1, 'profileVersion', 'u10-u12-prerequisite-profile-v1', + 'sourceSha256', 'sha256:' || (r.job->'documents'->>'content_hash'), 'sourceByteLength', 9, + 'sourceExpectation', expectation, 'sourceExpectationHex', encode(expectation_bytes, 'hex'), + 'sourceExpectationSha256', 'sha256:' || encode(extensions.digest(expectation_bytes, 'sha256'), 'hex'), + 'declarations', declarations), encode(convert_to('SYNTHETIC', 'UTF8'), 'hex')); + perform pg_temp.assert_true(result->'ok' = 'true'::jsonb, 'normal managed registration retains synthetic original'); + end loop; +end; +$register$; + +-- An older synthetic generation of the same document must also be immutable until +-- permanent deletion and must be removed with every other retained generation. +insert into ingestion_generation_evidence.candidates +select (jsonb_populate_record(null::ingestion_generation_evidence.candidates, + to_jsonb(c) || jsonb_build_object('generation_id', 'de000000-0000-4000-8000-000000000001'))).* +from ingestion_generation_evidence.candidates c where c.document_id = 'db000000-0000-4000-8000-000000000001'; +insert into ingestion_generation_evidence.producer_declarations +select (jsonb_populate_record(null::ingestion_generation_evidence.producer_declarations, + to_jsonb(p) || jsonb_build_object('generation_id', 'de000000-0000-4000-8000-000000000001'))).* +from ingestion_generation_evidence.producer_declarations p +where p.document_id = 'db000000-0000-4000-8000-000000000001' and p.generation_id <> 'de000000-0000-4000-8000-000000000001'; +insert into ingestion_generation_evidence.unit_declarations +select (jsonb_populate_record(null::ingestion_generation_evidence.unit_declarations, + to_jsonb(u) || jsonb_build_object('generation_id', 'de000000-0000-4000-8000-000000000001'))).* +from ingestion_generation_evidence.unit_declarations u +where u.document_id = 'db000000-0000-4000-8000-000000000001' and u.generation_id <> 'de000000-0000-4000-8000-000000000001'; +insert into ingestion_generation_evidence.retained_artifacts +select (jsonb_populate_record(null::ingestion_generation_evidence.retained_artifacts, + to_jsonb(a) || jsonb_build_object('generation_id', 'de000000-0000-4000-8000-000000000001'))).* +from ingestion_generation_evidence.retained_artifacts a +where a.document_id = 'db000000-0000-4000-8000-000000000001' and a.generation_id <> 'de000000-0000-4000-8000-000000000001'; + +-- Populate every remaining private representation using synthetic owner-only fixture rows. +do $derived$ +declare c ingestion_generation_evidence.candidates; projection uuid; visual uuid; + b bytea := convert_to('{}', 'UTF8'); h text; vector_value extensions.vector := array_fill(0::real, array[1536])::extensions.vector; +begin + h := 'sha256:' || encode(extensions.digest(b, 'sha256'), 'hex'); + for c in select * from ingestion_generation_evidence.candidates where document_id::text like 'db000000-%' loop + projection := gen_random_uuid(); visual := gen_random_uuid(); + insert into ingestion_generation_evidence.outcomes values + (c.document_id, c.generation_id, 'private_objects', 'original-source', 'private_objects', '{}'); + insert into ingestion_generation_evidence.prerequisite_accounting values + (c.document_id, c.generation_id, h, '{}', now()); + insert into ingestion_generation_evidence.scoped_projection_expectations values + (c.document_id, c.generation_id, projection, '{}', b, h, octet_length(b)); + insert into ingestion_generation_evidence.scoped_projection_rows values + (c.document_id, c.generation_id, projection, 'chunks', gen_random_uuid(), '{"content":"SYNTHETIC"}'); + insert into ingestion_generation_evidence.scoped_projection_artifacts values + (c.document_id, c.generation_id, projection, 'chunks', gen_random_uuid(), 'prepared', b, h, octet_length(b)); + insert into ingestion_generation_evidence.visual_unit_expectations values + (c.document_id, c.generation_id, visual, projection, b, b, b, '{}', h, h, h, '[{},{},{},{}]'); + insert into ingestion_generation_evidence.visual_unit_rows values + (c.document_id, c.generation_id, visual, 1, gen_random_uuid(), '{"content":"SYNTHETIC"}'); + insert into ingestion_generation_evidence.managed_vector_packets values + (c.document_id, c.generation_id, '{}', h, '{}', b, h); + insert into ingestion_generation_evidence.managed_vector_final_receipts values + (c.document_id, c.generation_id, 'chunks', gen_random_uuid(), b, h, octet_length(b)); + insert into ingestion_generation_evidence.candidate_chunk_rows(document_id, index_generation_id, chunk_index, + content, embedding, candidate_owner_id, candidate_job_id, candidate_lease_token, + candidate_authority_digest, candidate_source_sha256, candidate_source_expectation_digest, candidate_prepared_sha256) + values (c.document_id, c.generation_id, 0, 'SYNTHETIC', vector_value, null, c.job_id, c.first_lease_token, h, h, h, h); + insert into ingestion_generation_evidence.managed_vector_chunk_rows(document_id, index_generation_id, chunk_index, + content, embedding, packet_identity, prepared_sha256) + values (c.document_id, c.generation_id, 0, 'SYNTHETIC', vector_value, '{}', h); + insert into ingestion_generation_evidence.managed_vector_field_rows(document_id, index_generation_id, + field_type, content, content_hash, embedding, packet_identity, prepared_sha256) + values (c.document_id, c.generation_id, 'document_summary', 'SYNTHETIC', h, vector_value, '{}', h); + insert into ingestion_generation_evidence.managed_vector_index_rows(document_id, index_generation_id, + unit_type, title, content, embedding, packet_identity, prepared_sha256) + values (c.document_id, c.generation_id, 'document_profile', 'Synthetic', 'SYNTHETIC', vector_value, '{}', h); + end loop; +end; +$derived$; + +-- No deletion/overwrite/truncate of evidence while the document is still present. +do $immutable$ +begin + begin + delete from ingestion_generation_evidence.retained_artifacts where document_id = 'db000000-0000-4000-8000-000000000001'; + raise exception 'direct evidence delete unexpectedly succeeded'; + exception when check_violation then null; + end; + begin + update ingestion_generation_evidence.retained_artifacts set locator = 'changed' + where document_id = 'db000000-0000-4000-8000-000000000001'; + raise exception 'evidence overwrite unexpectedly succeeded'; + exception when check_violation then null; + end; + begin + truncate ingestion_generation_evidence.retained_artifacts; + raise exception 'evidence truncate unexpectedly succeeded'; + exception when check_violation then null; + end; +end; +$immutable$; + +select pg_temp.assert_true(public.delete_document_if_idle('db000000-0000-4000-8000-000000000001', + 'da000000-0000-4000-8000-000000000002', 'clinical-documents', 'clinical-images')->>'outcome' = 'not_found', + 'another owner cannot cause permanent deletion or purge'); +select pg_temp.assert_true(public.delete_document_if_idle('db000000-0000-4000-8000-000000000001', + 'da000000-0000-4000-8000-000000000001', 'clinical-documents', 'clinical-images')->>'outcome' = 'active_job', + 'active job prevents deletion and leaves retained evidence intact'); + +update public.ingestion_jobs set status = 'failed' where document_id = 'db000000-0000-4000-8000-000000000001'; +insert into public.indexing_v3_agent_jobs(document_id, status) +values ('db000000-0000-4000-8000-000000000001', 'pending'); +select pg_temp.assert_true(public.delete_document_if_idle('db000000-0000-4000-8000-000000000001', + 'da000000-0000-4000-8000-000000000001', 'clinical-documents', 'clinical-images')->>'outcome' = 'active_job', + 'agent-only pending work also prevents deletion and private purge'); +update public.indexing_v3_agent_jobs set status = 'failed' +where document_id = 'db000000-0000-4000-8000-000000000001'; + +-- A cleanup failure must preserve the parent and all private content atomically. +create function pg_temp.reject_private_purge() returns trigger language plpgsql as $$ begin + raise exception 'synthetic purge failure' using errcode = 'P0998'; +end $$; +create trigger zz_synthetic_purge_failure before delete on ingestion_generation_evidence.candidates + for each row execute function pg_temp.reject_private_purge(); +do $failure$ +begin + begin + perform public.delete_document_if_idle('db000000-0000-4000-8000-000000000001', + 'da000000-0000-4000-8000-000000000001', 'clinical-documents', 'clinical-images'); + raise exception 'synthetic purge failure was swallowed'; + exception when sqlstate 'P0998' then null; + end; + perform pg_temp.assert_true(exists (select 1 from public.documents + where id = 'db000000-0000-4000-8000-000000000001'), 'purge failure rolls back public deletion'); + perform pg_temp.assert_true(exists (select 1 from ingestion_generation_evidence.retained_artifacts + where document_id = 'db000000-0000-4000-8000-000000000001'), 'purge failure rolls back prior private deletions'); + perform pg_temp.assert_true(not exists (select 1 from public.storage_cleanup_jobs + where document_id = 'db000000-0000-4000-8000-000000000001' + or document_paths = array['synthetic/removal/1.txt']), 'purge failure rolls back cleanup enqueue'); +end; +$failure$; +drop trigger zz_synthetic_purge_failure on ingestion_generation_evidence.candidates; + +create temp table retention_delete_result as +select public.delete_document_if_idle('db000000-0000-4000-8000-000000000001', + 'da000000-0000-4000-8000-000000000001', 'clinical-documents', 'clinical-images') as result; +select pg_temp.assert_true((select result->>'outcome' = 'deleted' from retention_delete_result), + 'idle permanent deletion succeeds atomically'); +select pg_temp.assert_true(not exists (select 1 from public.documents where id = 'db000000-0000-4000-8000-000000000001'), + 'public document is removed'); +select pg_temp.assert_true(exists ( + select 1 from public.storage_cleanup_jobs j + join retention_delete_result r on j.id = (r.result->>'cleanup_job_id')::uuid + where j.document_id is null + and j.owner_id = 'da000000-0000-4000-8000-000000000001' + and j.document_bucket = 'clinical-documents' + and j.document_paths = array['synthetic/removal/1.txt'] and j.status = 'pending' +), 'returned Storage cleanup obligation survives with its deleted document reference cleared'); +select pg_temp.assert_true(exists (select 1 from public.audit_logs + where id = 'dc000000-0000-4000-8000-000000000001' and metadata = '{}'), 'content-free access-trail audit survives'); + +do $residual$ +declare t record; removed bigint; retained bigint; +begin + for t in select tablename from pg_catalog.pg_tables where schemaname = 'ingestion_generation_evidence' loop + execute format('select count(*) from ingestion_generation_evidence.%I where document_id = $1', t.tablename) + into removed using 'db000000-0000-4000-8000-000000000001'::uuid; + execute format('select count(*) from ingestion_generation_evidence.%I where document_id = $1', t.tablename) + into retained using 'db000000-0000-4000-8000-000000000002'::uuid; + perform pg_temp.assert_true(removed = 0, 'deleted document leaves no private representation in ' || t.tablename); + perform pg_temp.assert_true(retained > 0, 'another document retains private representation in ' || t.tablename); + end loop; +end; +$residual$; +select pg_temp.assert_true(public.delete_document_if_idle('db000000-0000-4000-8000-000000000001', + 'da000000-0000-4000-8000-000000000001', 'clinical-documents', 'clinical-images')->>'outcome' = 'not_found', + 'repeated permanent deletion is idempotent'); +rollback; diff --git a/tests/sql/private-ingestion-drift.sql b/tests/sql/private-ingestion-drift.sql new file mode 100644 index 0000000000..2d8a0094e2 --- /dev/null +++ b/tests/sql/private-ingestion-drift.sql @@ -0,0 +1,73 @@ +-- Disposable PostgreSQL only. Synthetic catalog mutations; each mutation rolls back. +\set ON_ERROR_STOP on +begin; +set local statement_timeout = '60s'; + +create function pg_temp.assert_true(ok boolean, message text) returns void +language plpgsql as $$ begin + if ok is distinct from true then raise exception '%', message; end if; +end $$; + +create function pg_temp.assert_private_drift(label text, mutation text) returns void +language plpgsql as $$ +declare before_snapshot jsonb; after_snapshot jsonb; +begin + before_snapshot := public.schema_drift_snapshot()->'private_objects'; + begin + execute mutation; + after_snapshot := public.schema_drift_snapshot()->'private_objects'; + if before_snapshot is not distinct from after_snapshot then + raise exception 'private drift invisible: %', label; + end if; + -- Roll back only the test mutation, preserving the assertion failure above. + raise exception using errcode = 'P0999', message = 'rollback successful drift probe'; + exception when sqlstate 'P0999' then null; + end; +end $$; + +select pg_temp.assert_true(jsonb_array_length(public.schema_drift_snapshot()->'private_objects') > 17, + 'private inventory includes relations, functions, schema and default privileges'); +select pg_temp.assert_true(not exists ( + select 1 from jsonb_array_elements(public.schema_drift_snapshot()->'private_objects') o + where o->>'identity' like '%auth.%' or o->>'identity' like '%storage.%' +), 'platform auth/storage objects are outside the explicit private inventory'); + +select pg_temp.assert_private_drift('function body', $mutation$ + create or replace function ingestion_generation_evidence.immutable() returns trigger + language plpgsql set search_path = '' as $body$ begin return old; end $body$ +$mutation$); +select pg_temp.assert_private_drift('function configuration', + $$alter function ingestion_generation_evidence.immutable() set work_mem = '1MB'$$); +select pg_temp.assert_private_drift('function ACL', + $$grant execute on function ingestion_generation_evidence.immutable() to service_role$$); +select pg_temp.assert_private_drift('schema ACL', + $$grant usage on schema ingestion_generation_evidence to service_role$$); +select pg_temp.assert_private_drift('table ACL', + $$grant select on ingestion_generation_evidence.retained_artifacts to service_role$$); +select pg_temp.assert_private_drift('column ACL', + $$grant select (artifact_bytes) on ingestion_generation_evidence.retained_artifacts to authenticated$$); +select pg_temp.assert_private_drift('schema default ACL', + $$alter default privileges for role postgres in schema ingestion_generation_evidence grant execute on functions to authenticated$$); +select pg_temp.assert_private_drift('global default ACL', + $$alter default privileges for role postgres grant select on tables to authenticated$$); +select pg_temp.assert_private_drift('immutability trigger enabled state', + $$alter table ingestion_generation_evidence.retained_artifacts disable trigger evidence_immutable$$); +select pg_temp.assert_private_drift('RLS', + $$alter table ingestion_generation_evidence.retained_artifacts disable row level security$$); +select pg_temp.assert_private_drift('FORCE RLS', + $$alter table ingestion_generation_evidence.retained_artifacts force row level security$$); +select pg_temp.assert_private_drift('constraint validation', + $$alter table ingestion_generation_evidence.retained_artifacts add constraint synthetic_positive_length check (byte_length > 0) not valid$$); +select pg_temp.assert_private_drift('policy', + $$create policy synthetic_drift_probe on ingestion_generation_evidence.retained_artifacts for select to authenticated using (true)$$); +select pg_temp.assert_private_drift('index', + $$create index synthetic_drift_probe on ingestion_generation_evidence.retained_artifacts (byte_length)$$); + +-- A catalog outside the explicit application schema has no private-inventory effect. +create schema synthetic_excluded_drift; +create table synthetic_excluded_drift.unrelated (id uuid); +create temp table private_before as select public.schema_drift_snapshot()->'private_objects' as snapshot; +alter table synthetic_excluded_drift.unrelated add column harmless text; +select pg_temp.assert_true((select snapshot = public.schema_drift_snapshot()->'private_objects' from private_before), + 'unrelated schemas remain excluded'); +rollback; diff --git a/tests/storage-restore-bytes.test.ts b/tests/storage-restore-bytes.test.ts new file mode 100644 index 0000000000..7d8e9c7c0c --- /dev/null +++ b/tests/storage-restore-bytes.test.ts @@ -0,0 +1,141 @@ +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + createStorageManifest, + runStorageRestoreCli, + STORAGE_RESTORE_BUCKETS, + validateStorageManifest, + verifyStorageRestore, +} from "../scripts/verify-storage-restore.mjs"; + +describe("offline Storage restore byte acceptance", () => { + let fixture: string; + let source: string; + let restored: string; + const objects = [ + ["clinical-documents", "synthetic/document.pdf", "%PDF-1.4\nsynthetic fixture\n%%EOF"], + ["clinical-images", "synthetic/image.svg", ''], + ["cme-private-evidence", "synthetic/evidence.txt", "wholly invented evidence"], + ]; + beforeEach(async () => { + fixture = await mkdtemp(path.join(tmpdir(), "psychsift-restore-bytes-")); + source = path.join(fixture, "source"); + restored = path.join(fixture, "restored"); + for (const root of [source, restored]) { + for (const bucket of STORAGE_RESTORE_BUCKETS) await mkdir(path.join(root, bucket), { recursive: true }); + for (const [bucket, name, content] of objects) { + await mkdir(path.dirname(path.join(root, bucket!, name!)), { recursive: true }); + await writeFile(path.join(root, bucket!, name!), content!); + } + } + }); + afterEach(async () => { + await rm(fixture, { recursive: true, force: true }); + }); + + it("reads and matches every object against source size and SHA-256", async () => { + const manifest = await createStorageManifest(source); + expect(manifest.objects).toHaveLength(3); + expect(await verifyStorageRestore(restored, manifest)).toMatchObject({ + ok: true, + expected: 3, + verified: 3, + failures: [], + }); + }); + + it("fails for omitted bytes even while the original inventory/metadata remains unchanged", async () => { + const manifest = await createStorageManifest(source); + await rm(path.join(restored, "clinical-documents", "synthetic/document.pdf")); + const result = await verifyStorageRestore(restored, manifest); + expect(manifest.objects).toHaveLength(3); + expect(result).toMatchObject({ ok: false, expected: 3, verified: 2 }); + expect(result.failures).toEqual([{ object: 1, reason: "missing_or_unreadable_bytes" }]); + }); + + it("fails for same-size corrupted bytes, which row counts and sizes cannot detect", async () => { + const manifest = await createStorageManifest(source); + const file = path.join(restored, "cme-private-evidence", "synthetic/evidence.txt"); + const original = await readFile(file); + original[0] = original[0]! ^ 1; + await writeFile(file, original); + expect(await verifyStorageRestore(restored, manifest)).toMatchObject({ + ok: false, + verified: 2, + failures: [{ reason: "size_or_digest_mismatch" }], + }); + }); + + it("fails for unreadable object shape and unexpected objects", async () => { + const manifest = await createStorageManifest(source); + const file = path.join(restored, "clinical-images", "synthetic/image.svg"); + await rm(file); + await mkdir(file); + await writeFile(path.join(restored, "clinical-documents", "extra.txt"), "unexpected"); + const result = await verifyStorageRestore(restored, manifest); + expect(result.ok).toBe(false); + expect(result.failures).toEqual( + expect.arrayContaining([{ object: 2, reason: "missing_or_unreadable_bytes" }, { reason: "unexpected_objects" }]), + ); + }); + + it("rejects symlinks without reading outside the selected root", async (context) => { + const manifest = await createStorageManifest(source); + const file = path.join(restored, "clinical-images", "synthetic/image.svg"); + await rm(file); + try { + await symlink(path.join(source, "clinical-images", "synthetic/image.svg"), file); + } catch (error) { + // Windows without the symlink privilege refuses creation; any other error stays visible. + if ((error as NodeJS.ErrnoException).code === "EPERM" && process.platform === "win32") { + context.skip(); + return; + } + throw error; + } + expect(await verifyStorageRestore(restored, manifest)).toMatchObject({ ok: false, verified: 2 }); + await expect(createStorageManifest(restored)).rejects.toThrow(/symlink/); + }); + + it("rejects traversal, duplicates, unsupported buckets and empty manifests", async () => { + const manifest = await createStorageManifest(source); + const object = manifest.objects[0]!; + for (const objects of [ + [], + [object, object], + [{ ...object, path: "../outside" }], + [{ ...object, bucket: "unknown" }], + ]) { + expect(() => validateStorageManifest({ ...manifest, objects })).toThrow(); + } + }); + + it("writes a private baseline once, verifies its exact digest, and does not overwrite it", async () => { + const manifestPath = path.join(fixture, "source-manifest.json"); + const created = await runStorageRestoreCli(["manifest", "--root", source, "--output", manifestPath]); + const verified = await runStorageRestoreCli(["verify", "--root", restored, "--manifest", manifestPath]); + expect(created).toMatchObject({ ok: true, objects: 3 }); + expect(verified).toMatchObject({ ok: true, manifestSha256: created.manifestSha256 }); + await expect(runStorageRestoreCli(["manifest", "--root", restored, "--output", manifestPath])).rejects.toThrow(); + await expect(runStorageRestoreCli(["verify", "--root", restored, "--root", source])).rejects.toThrow(/repeated/); + }); + + it("digests the manifest file bytes and refuses invalid UTF-8", async () => { + const manifestPath = path.join(fixture, "source-manifest.json"); + await runStorageRestoreCli(["manifest", "--root", source, "--output", manifestPath]); + const bytes = await readFile(manifestPath); + const verified = await runStorageRestoreCli(["verify", "--root", restored, "--manifest", manifestPath]); + expect(verified.manifestSha256).toBe(createHash("sha256").update(bytes).digest("hex")); + const marker = bytes.indexOf("synthetic/document.pdf"); + const tampered = Buffer.from(bytes); + tampered[marker] = 0xff; + const tamperedPath = path.join(fixture, "tampered-manifest.json"); + await writeFile(tamperedPath, tampered); + await expect(runStorageRestoreCli(["verify", "--root", restored, "--manifest", tamperedPath])).rejects.toThrow( + /UTF-8/, + ); + }); +}); diff --git a/tests/teaching-dual-sign-off-durable.test.ts b/tests/teaching-dual-sign-off-durable.test.ts index d601a04650..b32fdec230 100644 --- a/tests/teaching-dual-sign-off-durable.test.ts +++ b/tests/teaching-dual-sign-off-durable.test.ts @@ -127,6 +127,41 @@ function createMockRegistrarAssessmentsDb(initialRows: RegistrarAssessmentRow[] members, reviewers, rpc: vi.fn(async (fn: string, args: Record) => { + if (fn === "teaching_find_supervisor_overlap_v1") { + const found = [...rows.values()] + .filter((row) => { + const ratings = row.ratings as Record | null; + if ( + row.assessor_id !== args.p_supervisor_id || + row.category !== "supervisor_review" || + ratings?.kind !== "supervisor_booking" || + row.id === args.p_exclude_id + ) + return false; + const state = ["open", "booked", "completed", "cancelled"].includes(String(ratings.bookingState)) + ? ratings.bookingState + : row.status === "archived" + ? "cancelled" + : "active"; + if (state === "cancelled") return false; + const created = Date.parse(row.created_at), + before = Date.parse(String(args.p_before_created_at)); + if ( + args.p_before_created_at && + !(created < before || (created === before && row.id < String(args.p_before_id))) + ) + return false; + return ( + Date.parse(String(ratings.startsAt ?? row.created_at)) < Date.parse(String(args.p_ends_at)) && + Date.parse(String(ratings.endsAt ?? row.updated_at)) > Date.parse(String(args.p_starts_at)) + ); + }) + .sort( + (left, right) => + Date.parse(left.created_at) - Date.parse(right.created_at) || left.id.localeCompare(right.id), + ); + return { data: found.slice(0, 1).map((row) => structuredClone(row)), error: null } as never; + } if (fn !== "work_can" || args.p_capability !== "assessments.review") { return { data: null, error: { message: `Unexpected rpc: ${fn}` } }; } diff --git a/tests/teaching-overlap-malformed-times.test.ts b/tests/teaching-overlap-malformed-times.test.ts new file mode 100644 index 0000000000..4cc43e291f --- /dev/null +++ b/tests/teaching-overlap-malformed-times.test.ts @@ -0,0 +1,30 @@ +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +// The overlap RPC must skip a booking whose stored time is malformed, as the app check it +// replaced did (Date.parse -> NaN -> no overlap), rather than abort with a cast error. +const migrations = path.join(process.cwd(), "supabase/migrations"); +const latest = readdirSync(migrations) + .filter((file) => file.endsWith(".sql")) + .sort() + .filter((file) => + /create (or replace )?function public\.teaching_find_supervisor_overlap_v1\(/.test( + readFileSync(path.join(migrations, file), "utf8"), + ), + ) + .at(-1); + +describe("teaching_find_supervisor_overlap_v1", () => { + it("guards every stored-time cast in its latest definition", () => { + expect(latest).toBe("20261107000900_audit_overlap_skip_malformed_booking_times.sql"); + const sql = readFileSync(path.join(migrations, latest!), "utf8"); + for (const field of ["startsAt", "endsAt"]) { + const casts = sql.split(`(r.ratings->>'${field}')::timestamptz`).length - 1; + const guards = sql.split(`pg_input_is_valid(r.ratings->>'${field}', 'timestamptz')`).length - 1; + expect(casts).toBe(1); + expect(guards).toBe(casts); + } + }); +}); diff --git a/tests/teaching-supervisor-link-history.dom.test.tsx b/tests/teaching-supervisor-link-history.dom.test.tsx new file mode 100644 index 0000000000..45afb63b3c --- /dev/null +++ b/tests/teaching-supervisor-link-history.dom.test.tsx @@ -0,0 +1,102 @@ +/** @vitest-environment jsdom */ +import { cleanup, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn(), forget: vi.fn() })); +vi.mock("@/lib/shared-get", () => ({ forgetSharedGets: mocks.forget })); +vi.mock("@/lib/teaching/client", () => ({ + teachingGet: mocks.get, + teachingPost: mocks.post, + teachingErrorMessage: () => "Links unavailable", +})); +vi.mock("@/lib/example-data/store", () => ({ useExampleData: () => ({ active: false }) })); +import { SupervisorLinkPanel } from "@/components/teaching/assessments/supervisor-link-panel"; +import type { EpaMagicLinkSummary } from "@/lib/teaching/assessments/magic-link-repository"; + +const waiting: EpaMagicLinkSummary = { + id: "older-waiting", + epaCode: "EPA 1", + epaTitle: "Synthetic EPA", + supervisorName: "Dr Waiting Example", + supervisorRole: "consultant", + createdAt: "2026-10-20T00:00:00Z", + expiresAt: "2026-11-20T00:00:00Z", + status: "pending", + completedAt: null, + submission: null, +}; +const recent = { ...waiting, id: "recent", supervisorName: "Dr Recent Example", status: "revoked" as const }; +const older = { ...recent, id: "older", supervisorName: "Dr Older Example" }; +const initial = { links: [waiting, recent], nextCursor: "synthetic-cursor" }; +const mount = () => render(); + +beforeEach(() => vi.resetAllMocks()); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("owner supervisor-link history", () => { + it("keeps an older waiting link cancellable while loading older history without duplicates", async () => { + mocks.get.mockResolvedValueOnce(initial).mockResolvedValueOnce({ links: [waiting, older], nextCursor: null }); + const user = userEvent.setup(); + mount(); + await screen.findByText("EPA 1 · Dr Waiting Example"); + expect(screen.getByRole("button", { name: "Cancel link" })).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Load older links" })); + await screen.findByText("EPA 1 · Dr Older Example"); + expect(within(screen.getByTestId("epa-sent-links")).getAllByRole("listitem")).toHaveLength(3); + expect(mocks.get).toHaveBeenLastCalledWith(expect.stringContaining("cursor=synthetic-cursor")); + expect(screen.queryByRole("button", { name: "Load older links" })).toBeNull(); + }); + + it("preserves loaded links when an older history page fails and offers a retry", async () => { + mocks.get.mockResolvedValueOnce(initial).mockRejectedValueOnce(new Error("synthetic outage")); + const user = userEvent.setup(); + mount(); + await screen.findByText("EPA 1 · Dr Recent Example"); + await user.click(screen.getByRole("button", { name: "Load older links" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Older links could not be read"); + expect(screen.getByText("EPA 1 · Dr Recent Example")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Load older links" })).toBeEnabled(); + }); + + it("discards an older page started before a successful cancellation refresh", async () => { + let release!: (value: unknown) => void; + mocks.get + .mockResolvedValueOnce(initial) + .mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ) + .mockResolvedValueOnce({ links: [recent], nextCursor: null }); + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("{}", { status: 200 })); + const user = userEvent.setup(); + mount(); + await screen.findByText("EPA 1 · Dr Waiting Example"); + await user.click(screen.getByRole("button", { name: "Load older links" })); + await user.click(screen.getByRole("button", { name: "Cancel link" })); + await waitFor(() => expect(screen.queryByText("EPA 1 · Dr Waiting Example")).toBeNull()); + release({ links: [waiting, older], nextCursor: null }); + await waitFor(() => expect(screen.queryByText("EPA 1 · Dr Older Example")).toBeNull()); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/older-waiting"), + expect.objectContaining({ method: "DELETE" }), + ); + }); + + it("forgets in-flight link reads after a cancellation, before the refresh read starts", async () => { + mocks.get.mockResolvedValueOnce(initial).mockResolvedValueOnce({ links: [recent], nextCursor: null }); + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("{}", { status: 200 })); + const user = userEvent.setup(); + mount(); + await screen.findByText("EPA 1 · Dr Waiting Example"); + await user.click(screen.getByRole("button", { name: "Cancel link" })); + await waitFor(() => expect(mocks.get).toHaveBeenCalledTimes(2)); + expect(mocks.forget).toHaveBeenCalledWith("/api/teaching/assessments/magic-links"); + expect(mocks.forget.mock.invocationCallOrder[0]).toBeLessThan(mocks.get.mock.invocationCallOrder[1]); + }); +}); diff --git a/tests/work-bookings-concurrent-withdrawal.test.ts b/tests/work-bookings-concurrent-withdrawal.test.ts index 7245f5086f..cafee6f68e 100644 --- a/tests/work-bookings-concurrent-withdrawal.test.ts +++ b/tests/work-bookings-concurrent-withdrawal.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { DurableSupervisorBookingRepository } from "@/lib/teaching/assessments/supervisor-bookings"; // Execute the real repository against a statement-level fake which books the @@ -8,7 +8,29 @@ describe("concurrent availability withdrawal", () => { "preserves a concurrent booking (%s) while withdrawing only unchanged open rows", async (bookDuringCheck) => { let stored: Record | null = null; + let overlapReads = 0; const client = { + rpc: async (name: string, args: Record) => { + expect(name).toBe("teaching_find_supervisor_overlap_v1"); + expect(args).toMatchObject({ + p_supervisor_id: "supervisor", + p_starts_at: "2026-10-19T09:00:00Z", + p_ends_at: "2026-10-19T10:00:00Z", + p_exclude_id: "new-slot", + }); + overlapReads += 1; + if (!stored) return { data: [], error: null }; + expect(args.p_before_id).toBe("new-slot"); + if (bookDuringCheck) { + stored.status = "submitted"; + stored.updated_at = "2099-01-01T00:00:00Z"; + stored.ratings = { ...(stored.ratings as object), bookingState: "booked", traineeId: "trainee" }; + } + return { + data: [{ ...structuredClone(stored), id: "earlier-slot", created_at: "2020-01-01T00:00:00Z" }], + error: null, + }; + }, from: () => { const filters: [string, unknown][] = []; let patch: Record | null = null; @@ -60,6 +82,7 @@ describe("concurrent availability withdrawal", () => { if (bookDuringCheck) await expect(created).resolves.toMatchObject({ bookingState: "booked", traineeId: "trainee" }); else await expect(created).rejects.toThrow(/overlapping/); + expect(overlapReads).toBe(2); expect(stored).toMatchObject( bookDuringCheck ? { status: "submitted", ratings: { bookingState: "booked", traineeId: "trainee" } } @@ -68,3 +91,23 @@ describe("concurrent availability withdrawal", () => { }, ); }); + +describe("bounded supervisor overlap authority", () => { + it("refuses missing typed overlap SQL before publishing a slot, without a history-scan fallback", async () => { + const rpc = vi.fn().mockResolvedValue({ data: null, error: { code: "PGRST202", message: "Missing RPC" } }); + const from = vi.fn(() => { + throw new Error("Unexpected legacy history read"); + }); + const repo = new DurableSupervisorBookingRepository({ rpc, from } as never); + await expect( + repo.createAvailabilitySlot({ + serviceId: "service", + supervisorId: "supervisor", + startsAt: "2026-10-19T09:00:00+08:00", + endsAt: "2026-10-19T10:00:00+08:00", + }), + ).rejects.toMatchObject({ status: 503 }); + expect(rpc).toHaveBeenCalledTimes(1); + expect(from).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/work-roles-people-changes.test.ts b/tests/work-roles-people-changes.test.ts index 5bbb1bee0b..6447cfef4b 100644 --- a/tests/work-roles-people-changes.test.ts +++ b/tests/work-roles-people-changes.test.ts @@ -75,12 +75,19 @@ const workforce: WorkRoleContext = { grants: [{ role: "workforce", hospitalId: H1, serviceIds: [TEAM, OTHER_TEAM] }], }; -async function refusal(promise: Promise): Promise<{ status: number; code?: string; message: string }> { +async function refusal( + promise: Promise, +): Promise<{ status: number; code?: string; causeName?: string | null; message: string }> { try { await promise; } catch (error) { if (error instanceof PublicApiError) - return { status: error.status, code: error.details?.code, message: error.message }; + return { + status: error.status, + code: error.details?.code, + causeName: error.details?.causeName, + message: error.message, + }; throw error; } throw new Error("expected a refusal"); @@ -276,7 +283,102 @@ describe("rename a hospital", () => { }); }); +describe("link a hospital team from stale People selection", () => { + it("maps an atomic archived-destination refusal after a successful preflight", async () => { + const db = fakeDb(seed()); + db.tables.work_hospital_teams = []; + const originalFrom = db.client.from.bind(db.client); + db.client.from = ((table: string) => + table === "work_hospital_teams" + ? { insert: async () => ({ error: { code: "P0001", message: "work_hospital_unavailable" } }) } + : originalFrom(table as never)) as unknown as typeof db.client.from; + const answer = await refusal( + applyPeopleChange(db.client, admin, { action: "link-team", hospitalId: H2, serviceId: TEAM }), + ); + expect(answer).toMatchObject({ status: 404, code: "work_people_not_found" }); + expect(db.writes).toEqual([]); + }); + + it("reports a saved mutation separately from a failed People refresh", async () => { + const db = fakeDb(seed()); + db.tables.work_hospital_teams = []; + const originalFrom = db.client.from.bind(db.client); + db.client.from = ((table: string) => { + if (table === "work_hospitals" && db.writes.length > 0) { + return { + select: () => ({ + is: () => ({ + order: () => ({ + limit: async () => ({ + data: null, + error: { code: "08006", message: "synthetic read failure" }, + }), + }), + }), + }), + }; + } + return originalFrom(table as never); + }) as unknown as typeof db.client.from; + const answer = await refusal( + applyPeopleChange(db.client, admin, { + action: "link-team", + hospitalId: H2, + serviceId: TEAM, + }), + ); + expect(answer).toMatchObject({ + status: 503, + code: "work_people_refresh_failed", + causeName: "work_people_unavailable", + }); + expect(answer.message).toMatch(/change was saved/i); + expect(db.tables.work_hospital_teams).toContainEqual( + expect.objectContaining({ service_id: TEAM, hospital_id: H2 }), + ); + }); + + it("refuses an archived destination with zero writes and keeps the team discoverable", async () => { + const db = fakeDb(seed()); + db.tables.work_hospital_teams = []; + const answer = await refusal( + applyPeopleChange(db.client, admin, { + action: "link-team", + hospitalId: H3, + serviceId: TEAM, + }), + ); + expect(answer).toMatchObject({ status: 404, code: "work_people_not_found" }); + expect(db.writes).toEqual([]); + expect(db.tables.work_hospital_teams).toEqual([]); + }); +}); + describe("archive a hospital", () => { + it("maps a linked-team guard refusal after an empty-team preflight without claiming a save", async () => { + const db = fakeDb(seed()); + const originalFrom = db.client.from.bind(db.client); + db.client.from = ((table: string) => + table === "work_hospitals" + ? { + update: () => ({ + eq: () => ({ + is: () => ({ + select: async () => ({ + data: null, + error: { code: "P0001", message: "work_hospital_has_teams" }, + }), + }), + }), + }), + } + : originalFrom(table as never)) as unknown as typeof db.client.from; + const answer = await refusal(applyPeopleChange(db.client, admin, { action: "archive-hospital", hospitalId: H2 })); + expect(answer).toMatchObject({ status: 409, code: "work_hospital_has_teams" }); + expect(answer.message).toMatch(/^Move its teams first/); + expect(db.writes).toEqual([]); + }); + it("refuses while teams are linked, and says to move them first", async () => { const db = fakeDb(seed()); const answer = await refusal(applyPeopleChange(db.client, admin, { action: "archive-hospital", hospitalId: H1 })); diff --git a/tests/worker-shadow-environment.test.ts b/tests/worker-shadow-environment.test.ts new file mode 100644 index 0000000000..4618b574fe --- /dev/null +++ b/tests/worker-shadow-environment.test.ts @@ -0,0 +1,151 @@ +import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { buildDoclingShadowEnvironment, runDoclingShadowScript } from "../worker/shadow-extraction"; + +// Launches a real task-owned child process, standing in for the Docling interpreter. It +// records booleans and synthetic paths only. This proves process-environment/cleanup wiring, +// not Docling compatibility, PDF parsing or an OS filesystem/network sandbox. +describe.skipIf(process.platform === "win32")("shadow child environment and owned-directory cleanup", () => { + let fixture: string; + let ambientTemp: string; + let artifacts: string; + let launcher: string; + let trace: string; + + beforeEach(async () => { + fixture = await mkdtemp(path.join(tmpdir(), "psychsift-shadow-env-test-")); + ambientTemp = path.join(fixture, "ambient-temp"); + artifacts = path.join(fixture, "model-artifacts"); + launcher = path.join(fixture, "fake-python"); + trace = path.join(fixture, "child-trace.json"); + await mkdir(ambientTemp); + await mkdir(artifacts); + await writeFile(path.join(artifacts, "retain-model.txt"), "synthetic retained model"); + await mkdir(path.join(fixture, "outside-artifact-cache")); + await writeFile(path.join(fixture, "outside-artifact-cache", "retain.txt"), "synthetic external cache"); + vi.stubEnv("TMPDIR", ambientTemp); + vi.stubEnv("HOME", path.join(fixture, "ambient-home")); + vi.stubEnv("DOCLING_ARTIFACTS_PATH", artifacts); + vi.stubEnv("HF_HOME", path.join(artifacts, "hf-home")); + vi.stubEnv("HF_HUB_CACHE", path.join(artifacts, "hub")); + vi.stubEnv("TRANSFORMERS_CACHE", path.join(artifacts, "transformers")); + vi.stubEnv("HF_DATASETS_CACHE", path.join(fixture, "outside-artifact-cache")); + vi.stubEnv("HF_HUB_OFFLINE", "0"); + vi.stubEnv("TRANSFORMERS_OFFLINE", "0"); + vi.stubEnv("HF_DATASETS_OFFLINE", "0"); + vi.stubEnv("OPENAI_API_KEY", "synthetic-shadow-test-secret"); + vi.stubEnv("SUPABASE_SERVICE_ROLE_KEY", "synthetic-shadow-test-secret"); + vi.stubEnv("SENTRY_AUTH_TOKEN", "synthetic-shadow-test-secret"); + vi.stubEnv("HTTPS_PROXY", "https://synthetic.invalid"); + vi.stubEnv("PYTHONPATH", "/synthetic/injection"); + vi.stubEnv("NODE_OPTIONS", "--stack-trace-limit=15"); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await rm(fixture, { recursive: true, force: true }); + }); + + async function fakeInterpreter(mode: "success" | "crash" | "timeout") { + await writeFile( + launcher, + `#!${process.execPath} +const fs = require("node:fs"); +const path = require("node:path"); +const [, pdfPath, resultPath] = process.argv.slice(2); +const workDir = path.dirname(pdfPath); +const state = { + workDir, + home: process.env.HOME, + tmp: process.env.TMPDIR, + cache: process.env.XDG_CACHE_HOME, + artifacts: process.env.DOCLING_ARTIFACTS_PATH, + hfHome: process.env.HF_HOME, + hubCache: process.env.HF_HUB_CACHE, + transformersCache: process.env.TRANSFORMERS_CACHE, + datasetCache: process.env.HF_DATASETS_CACHE, + hasPath: Boolean(process.env.PATH), + hasCredentials: ["OPENAI_API_KEY", "SUPABASE_SERVICE_ROLE_KEY", "SENTRY_AUTH_TOKEN"].some(key => Boolean(process.env[key])), + hasInjection: ["PYTHONPATH", "NODE_OPTIONS", "HTTPS_PROXY"].some(key => Boolean(process.env[key])), + offline: ["HF_HUB_OFFLINE", "TRANSFORMERS_OFFLINE", "HF_DATASETS_OFFLINE"].every(key => process.env[key] === "1"), + privateDirectoriesExist: ["HOME", "TMPDIR", "XDG_CACHE_HOME"].every(key => Boolean(process.env[key]) && fs.existsSync(process.env[key])), +}; +fs.writeFileSync(${JSON.stringify(trace)}, JSON.stringify(state)); +fs.writeFileSync(resultPath, JSON.stringify({ engine: "docling", ok: true, state })); +${mode === "timeout" ? "setInterval(() => {}, 1000);" : `process.exit(${mode === "crash" ? 7 : 0});`} +`, + ); + await chmod(launcher, 0o700); + } + + it.each(["success", "crash", "timeout"] as const)( + "isolates credentials/cache config and cleans only owned files after %s", + async (mode) => { + await fakeInterpreter(mode); + const result = await runDoclingShadowScript({ + pythonBin: launcher, + buffer: Buffer.from("synthetic PDF bytes"), + timeoutMs: mode === "timeout" ? 1000 : 5000, + }); + const state = JSON.parse(await readFile(trace, "utf8")); + expect(result).toMatchObject({ + timedOut: mode === "timeout", + exitCode: mode === "timeout" ? null : mode === "crash" ? 7 : 0, + }); + expect(state).toMatchObject({ + hasCredentials: false, + hasInjection: false, + offline: true, + hasPath: true, + privateDirectoriesExist: true, + home: path.join(state.workDir, "home"), + tmp: path.join(state.workDir, "tmp"), + cache: path.join(state.workDir, "cache"), + artifacts, + hfHome: path.join(artifacts, "hf-home"), + hubCache: path.join(artifacts, "hub"), + transformersCache: path.join(artifacts, "transformers"), + }); + expect(state.datasetCache).toBe(path.join(state.workDir, "cache", "huggingface", "datasets")); + await expect(stat(state.workDir)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readdir(ambientTemp)).toEqual([]); + expect(await readFile(path.join(artifacts, "retain-model.txt"), "utf8")).toBe("synthetic retained model"); + expect(await readFile(path.join(fixture, "outside-artifact-cache", "retain.txt"), "utf8")).toBe( + "synthetic external cache", + ); + }, + 10_000, + ); + + it("removes its generated directories on spawn failure without touching artifact caches", async () => { + const result = await runDoclingShadowScript({ + pythonBin: launcher, + buffer: Buffer.from("synthetic PDF bytes"), + timeoutMs: 1000, + }); + expect(result.spawnErrorCode).toBe("ENOENT"); + expect(await readdir(ambientTemp)).toEqual([]); + expect(await readFile(path.join(artifacts, "retain-model.txt"), "utf8")).toBe("synthetic retained model"); + }); + + it("does not confuse sibling/traversal cache paths with the configured artifact tree", () => { + const workDir = path.join(fixture, "owned-run"); + for (const cachePath of [`${artifacts}-sibling/hub`, path.join(artifacts, "..", "outside"), "/outside/hub"]) { + const environment = buildDoclingShadowEnvironment(workDir, { + NODE_ENV: "test", + DOCLING_ARTIFACTS_PATH: artifacts, + HF_HUB_CACHE: cachePath, + UNRECOGNIZED_CREDENTIAL: "synthetic credential", + HF_TOKEN: "synthetic token", + LD_PRELOAD: "/synthetic/injection", + }); + expect(environment.HF_HUB_CACHE).toBe(path.join(workDir, "cache", "huggingface", "hub")); + expect(environment.NODE_ENV).toBe("production"); + expect(environment.UNRECOGNIZED_CREDENTIAL).toBeUndefined(); + expect(environment.HF_TOKEN).toBeUndefined(); + expect(environment.LD_PRELOAD).toBeUndefined(); + } + }); +}); diff --git a/worker/native/README.md b/worker/native/README.md index 693ca7503d..f9ab95e58d 100644 --- a/worker/native/README.md +++ b/worker/native/README.md @@ -68,8 +68,18 @@ not listed either: the owner chose a backport without a suppression, so the scan keeps reporting it until Debian publishes a fixed version. Suppressed and remaining per-CVE diagnostics go to stderr in the CI log, the retained report stays unfiltered, and stdout keeps the strict aggregate scan records (with a -`SUPPRESSED=` count). Pillow 12.3.0 in the Docling venv is also listed until -Docling allows Pillow 13; that entry is an accepted risk, not a remediation. +`SUPPRESSED=` count). Pillow 12.3.0 in the current Docling venv is also listed; +the pinned Docling 2.139.0 dependency contract requires Pillow <13. Those entries +are accepted risk, not remediation. A public registry metadata review on +2026-11-05 confirmed that [Docling-slim 2.142.0](https://pypi.org/pypi/docling-slim/2.142.0/json) +supports Pillow 13, while the inspected 2.140.0 and 2.141.0 releases still cap it +below 13. This is an available upgrade candidate, not an installed fix: it also +requires newer Docling core/parse packages, Python 3.11 lock regeneration, model +artifact identity verification and real synthetic Docling/Tesseract acceptance. +Do not override the current package contract, hand-edit hashes or renew an +acceptance date to manufacture a clean report. The shadow child now receives an +explicit environment and private temporary/home paths, but that is exposure +reduction, not an OS sandbox or a fix for the underlying Pillow advisories. ## Pinned Debian sources diff --git a/worker/shadow-extraction.ts b/worker/shadow-extraction.ts index 9ceae81f8e..93c1cd274f 100644 --- a/worker/shadow-extraction.ts +++ b/worker/shadow-extraction.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { z } from "zod"; @@ -349,6 +349,78 @@ function appendTail(current: string, chunk: Buffer | string) { return next.length > STREAM_TAIL_MAX_BYTES ? next.slice(next.length - STREAM_TAIL_MAX_BYTES) : next; } +/** + * The parser receives untrusted original bytes after the legacy index has committed. Pass + * runtime settings explicitly instead of inheriting the worker's credentials, proxies and + * interpreter injection variables. This reduces exposure; it is not an OS sandbox. + * Model caches may be shared only inside the explicitly configured artifact directory. + */ +export function buildDoclingShadowEnvironment( + workDir: string, + environment: Partial = process.env, +): NodeJS.ProcessEnv { + // Next augments ProcessEnv with required NODE_ENV. Keep it explicit and benign; + // the Python child must never inherit the parent's environment wholesale. + const childEnvironment: NodeJS.ProcessEnv = { NODE_ENV: "production" }; + for (const key of [ + "PATH", + "LANG", + "LC_ALL", + "LC_CTYPE", + "TZ", + "SystemRoot", + "SYSTEMROOT", + "WINDIR", + "COMSPEC", + "PATHEXT", + "TESSDATA_PREFIX", + "OMP_NUM_THREADS", + "MKL_NUM_THREADS", + "OPENBLAS_NUM_THREADS", + "NUMEXPR_NUM_THREADS", + "TOKENIZERS_PARALLELISM", + ]) { + if (environment[key] !== undefined) childEnvironment[key] = environment[key]; + } + const artifacts = environment.DOCLING_ARTIFACTS_PATH?.trim(); + if (artifacts) childEnvironment.DOCLING_ARTIFACTS_PATH = artifacts; + const artifactCache = (value: string | undefined) => { + if (!artifacts || !path.isAbsolute(artifacts) || !value || !path.isAbsolute(value)) return undefined; + const root = path.resolve(artifacts); + if (root === path.parse(root).root) return undefined; + const relative = path.relative(root, path.resolve(value)); + return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) ? value : undefined; + }; + const cache = path.join(workDir, "cache"); + const hfHome = artifactCache(environment.HF_HOME) ?? path.join(cache, "huggingface"); + const hub = + artifactCache(environment.HF_HUB_CACHE) ?? + artifactCache(environment.HUGGINGFACE_HUB_CACHE) ?? + path.join(hfHome, "hub"); + return { + ...childEnvironment, + HOME: path.join(workDir, "home"), + USERPROFILE: path.join(workDir, "home"), + TMPDIR: path.join(workDir, "tmp"), + TEMP: path.join(workDir, "tmp"), + TMP: path.join(workDir, "tmp"), + XDG_CACHE_HOME: cache, + HF_HOME: hfHome, + HF_HUB_CACHE: hub, + HUGGINGFACE_HUB_CACHE: artifactCache(environment.HUGGINGFACE_HUB_CACHE) ?? hub, + HF_DATASETS_CACHE: artifactCache(environment.HF_DATASETS_CACHE) ?? path.join(cache, "huggingface", "datasets"), + TRANSFORMERS_CACHE: artifactCache(environment.TRANSFORMERS_CACHE) ?? hub, + TORCH_HOME: artifactCache(environment.TORCH_HOME) ?? path.join(cache, "torch"), + PYTHONDONTWRITEBYTECODE: "1", + PYTHONUNBUFFERED: "1", + TORCHDYNAMO_DISABLE: "1", + HF_HUB_OFFLINE: "1", + TRANSFORMERS_OFFLINE: "1", + HF_DATASETS_OFFLINE: "1", + HF_HUB_DISABLE_TELEMETRY: "1", + }; +} + /** * Default runner: writes the PDF to a private temp dir, spawns the docling venv interpreter on * worker/python/shadow_docling_extract.py, kills the whole process tree at the deadline, and @@ -363,16 +435,15 @@ export const runDoclingShadowScript: ShadowScriptRunner = async (input) => { const resultPath = path.join(workDir, "result.json"); const startedAt = Date.now(); try { + for (const directory of ["home", "tmp", "cache"]) { + await mkdir(path.join(workDir, directory), { mode: 0o700 }); + } await writeFile(pdfPath, input.buffer); const spawnResult = await new Promise & { stderrTail: string }>( (resolve) => { const child = spawn(input.pythonBin, [scriptPath, pdfPath, resultPath], { cwd: process.cwd(), - env: { - ...process.env, - TORCHDYNAMO_DISABLE: "1", - HF_HUB_OFFLINE: process.env.HF_HUB_OFFLINE ?? "1", - }, + env: buildDoclingShadowEnvironment(workDir), stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32", windowsHide: true,