From 4973037a980a2e4b8142f7b5e4a5d17c97064066 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 11:39:14 +0000 Subject: [PATCH 1/2] fix: remediate full-deep audit findings and retain release holds Clean rebuild of PR #3640 (head 83a49c5fa) as one commit on main 1837b0b41, with identical final code (the tree equals merging main into that head). Rebuilt because qodo-code-review[bot] commits in #3640's history (577dc4fa8, a62fc9541) touch protected paths and fail the bot-authored-sensitive-commits PR policy, and history rewrites are banned. Those bot changes appear here only as they stood at 83a49c5fa and need owner review in this PR. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018UBtJDgQZJ3eBsxLWte9r4 --- data/repo-awareness-snapshot.json | 30 + docs/README.md | 9 + docs/agents/codex-cloud-legacy-bootstrap.md | 24 + ...026-10-17-full-deep-remediation-results.md | 564 ++++++++++++++++ .../2026-10-17-full-deep-remediation-spec.md | 181 +++++ docs/database-drift-detection.md | 40 ++ docs/error-tracking.md | 8 +- ...-release-remediation-handoff-2026-10-17.md | 66 ++ ...hysiological-calculator-hold-2026-10-17.md | 33 + docs/observability-slos.md | 150 +++-- docs/organisation/systems/data-platform.json | 3 + .../systems/personal-practice.json | 1 + docs/rag-behaviour/README.md | 1 + docs/rag-behaviour/cache-freshness.md | 82 +++ docs/rag-injection-threat-model.md | 2 +- docs/reindex-runbook.md | 52 ++ docs/scripts-index.md | 2 +- .../plans/2026-10-17-full-deep-remediation.md | 309 +++++++++ railway.worker.json | 2 + scripts/check-worker-python-lock.mjs | 6 +- scripts/generate-drift-manifest.ts | 211 ++++-- scripts/generate-worker-python-lock.mjs | 6 +- scripts/lib/drift-rootless.ts | 616 ++++++++++++++++++ scripts/recover-ingestion-queue.ts | 210 +----- scripts/reindex.ts | 118 ++-- scripts/test-run-lock.mjs | 20 +- scripts/worker-python-lock-config.mjs | 2 + src/app/api/documents/[id]/reviews/route.ts | 2 + src/app/api/upload/route.ts | 52 +- src/app/api/work/hospital/sick/route.ts | 7 + src/components/account-data-provider.tsx | 120 +++- src/components/admin/admin-new-job-page.tsx | 29 +- .../admin/junior/use-roster-leave.ts | 13 +- .../admin/new-job/admin-new-job-sharing.tsx | 29 +- .../calculators/clinical-math-panel.tsx | 25 + .../roster/manage/roster-cover-tab.tsx | 35 +- src/components/roster/roster-month-page.tsx | 17 +- .../roster/roster-settings-page.tsx | 29 +- src/components/roster/use-roster-links.ts | 122 ++-- src/components/roster/use-roster-settings.ts | 38 +- src/components/roster/use-roster-shifts.ts | 100 ++- src/components/roster/use-roster-team.ts | 17 +- src/components/teaching/organise-model.ts | 20 +- src/components/use-account-request-scope.ts | 34 + .../use-work-calendar-entries.ts | 14 +- .../work-screens/admin/pay-page.tsx | 18 +- src/instrumentation.ts | 166 +++-- .../calculators/clinical-math/governance.json | 95 +++ src/lib/cme/evidence-repository.ts | 22 +- src/lib/ingestion-recovery.ts | 68 +- src/lib/logger.ts | 2 +- src/lib/observability/agent-monitoring.ts | 7 +- src/lib/open-shifts/mine.ts | 8 +- src/lib/open-shifts/posted.ts | 4 +- src/lib/privacy.ts | 54 ++ src/lib/rag/rag-answer-coalescing.ts | 87 +++ src/lib/rag/rag-cache.ts | 214 +++--- src/lib/rag/rag.ts | 59 +- src/lib/site-content/adapters/index.ts | 14 +- src/lib/supabase/database.types.ts | 68 +- src/lib/trusted-document-admission.ts | 26 +- src/lib/work-roles/people.ts | 41 +- supabase/applied-migration-hashes.json | 5 +- supabase/drift-manifest.json | 83 ++- ...able_recovery_pairing_and_cache_fences.sql | 569 ++++++++++++++++ supabase/schema.sql | 571 ++++++++++++++++ tests/account-request-scope.dom.test.tsx | 121 ++++ tests/admin-auth-transition.dom.test.tsx | 95 +++ tests/admin-new-job-page.dom.test.tsx | 37 +- tests/admin-pay-history.dom.test.tsx | 176 +++++ tests/agent-monitoring.test.ts | 76 +++ tests/clinical-math-bounds.test.ts | 56 +- tests/clinical-math-governance.dom.test.tsx | 30 + tests/clinical-math-governance.test.ts | 55 ++ ...linical-math-panel-breakpoint.dom.test.tsx | 39 +- tests/cme-evidence-api.test.ts | 28 +- tests/diagnostic-minimisation.test.ts | 96 +++ tests/drift-rootless.test.ts | 399 ++++++++++++ tests/factsheets-summary-context.test.ts | 34 + tests/favourites-session-race.dom.test.tsx | 320 +++++++++ .../static-manifest-baseline.json | 38 +- tests/hosted-migration-role-guard.test.ts | 2 +- tests/ingestion-recovery.test.ts | 82 ++- tests/instrumentation.test.ts | 77 +++ tests/open-shifts-zone.test.ts | 116 ++++ tests/operational-alerts.test.ts | 18 + tests/rag-answer-fallback.test.ts | 172 +++-- tests/rag-cache-governance-revocation.test.ts | 505 ++++++++++++++ tests/rag-cache-indexing-stamp.test.ts | 74 ++- tests/rag-cache-invalidation.test.ts | 177 ++--- tests/rag-cache-lru.test.ts | 72 +- tests/rag-cache-test-revision.ts | 67 ++ tests/rag-shared-cache.test.ts | 183 +++--- tests/rag-site-content-freshness.test.ts | 43 +- tests/rag-tail-latency.test.ts | 206 ++++-- tests/railway-config.test.ts | 2 + tests/retrieval-access-scope.test.ts | 84 ++- tests/roster-auth-transition.dom.test.tsx | 257 ++++++++ tests/roster-cover-tab.dom.test.tsx | 62 +- tests/roster-month-page.dom.test.tsx | 31 +- tests/test-runner-safety.test.ts | 175 +++++ tests/trusted-document-admission.test.ts | 26 +- tests/ui-tools-search-mode-mockup.spec.ts | 2 + tests/ui-tools.spec.ts | 32 + tests/upload-diagnostic-minimisation.test.ts | 198 ++++++ ...work-calendar-entries-enabled.dom.test.tsx | 17 +- .../work-roles-hospital-sick-release.test.ts | 102 +++ tests/work-roles-hospital-sick.test.ts | 17 + tests/work-roles-people-changes.test.ts | 51 +- tests/worker-python-lock.test.ts | 22 + worker/main.ts | 14 +- worker/python/requirements-cloud.in | 6 + worker/python/requirements-cloud.txt | 185 ++++-- 113 files changed, 8953 insertions(+), 1356 deletions(-) create mode 100644 docs/audit/2026-10-17-full-deep-remediation-results.md create mode 100644 docs/audit/2026-10-17-full-deep-remediation-spec.md create mode 100644 docs/governance/clinical-release-remediation-handoff-2026-10-17.md create mode 100644 docs/governance/physiological-calculator-hold-2026-10-17.md create mode 100644 docs/rag-behaviour/cache-freshness.md create mode 100644 docs/superpowers/plans/2026-10-17-full-deep-remediation.md create mode 100644 scripts/lib/drift-rootless.ts create mode 100644 src/components/use-account-request-scope.ts create mode 100644 src/lib/calculators/clinical-math/governance.json create mode 100644 src/lib/rag/rag-answer-coalescing.ts create mode 100644 supabase/migrations/20261018082910_audit_durable_recovery_pairing_and_cache_fences.sql create mode 100644 tests/account-request-scope.dom.test.tsx create mode 100644 tests/admin-auth-transition.dom.test.tsx create mode 100644 tests/admin-pay-history.dom.test.tsx create mode 100644 tests/clinical-math-governance.dom.test.tsx create mode 100644 tests/clinical-math-governance.test.ts create mode 100644 tests/diagnostic-minimisation.test.ts create mode 100644 tests/drift-rootless.test.ts create mode 100644 tests/factsheets-summary-context.test.ts create mode 100644 tests/favourites-session-race.dom.test.tsx create mode 100644 tests/open-shifts-zone.test.ts create mode 100644 tests/rag-cache-governance-revocation.test.ts create mode 100644 tests/rag-cache-test-revision.ts create mode 100644 tests/roster-auth-transition.dom.test.tsx create mode 100644 tests/upload-diagnostic-minimisation.test.ts create mode 100644 tests/work-roles-hospital-sick-release.test.ts create mode 100644 worker/python/requirements-cloud.in diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index 64f07e9e85..50f8800a71 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -3153,6 +3153,11 @@ "section": "root", "catalogued": true }, + { + "path": "docs/audit/2026-10-17-full-deep-remediation-results.md", + "section": "audit", + "catalogued": true + }, { "path": "docs/superpowers/plans/2026-09-01-calculators-clinical-safety.md", "section": "superpowers", @@ -3448,6 +3453,11 @@ "section": "root", "catalogued": true }, + { + "path": "docs/governance/clinical-release-remediation-handoff-2026-10-17.md", + "section": "governance", + "catalogued": true + }, { "path": "docs/decisions/2026-09-04-on-call-design-choices.md", "section": "decisions", @@ -4123,6 +4133,11 @@ "section": "decisions", "catalogued": true }, + { + "path": "docs/audit/2026-10-17-full-deep-remediation-spec.md", + "section": "audit", + "catalogued": true + }, { "path": "docs/decisions/2026-08-07-weak-evidence-banner-only-when-it-adds.md", "section": "decisions", @@ -5283,6 +5298,11 @@ "section": "redesign", "catalogued": true }, + { + "path": "docs/governance/physiological-calculator-hold-2026-10-17.md", + "section": "governance", + "catalogued": true + }, { "path": "docs/decisions/2026-10-15-no-default-ai-coding-tool.md", "section": "decisions", @@ -5298,6 +5318,11 @@ "section": "brand", "catalogued": true }, + { + "path": "docs/rag-behaviour/cache-freshness.md", + "section": "rag-behaviour", + "catalogued": true + }, { "path": "docs/audit/codex-cloud-reliability-20260907.md", "section": "audit", @@ -5378,6 +5403,11 @@ "section": "root", "catalogued": true }, + { + "path": "docs/superpowers/plans/2026-10-17-full-deep-remediation.md", + "section": "superpowers", + "catalogued": true + }, { "path": "docs/audit/worktree-reconciliation-2026-08-23.md", "section": "audit", diff --git a/docs/README.md b/docs/README.md index b4d5f731d2..89608357f7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -284,6 +284,15 @@ Every remaining tracked document in this category (process, plus the `agents/` r - [plans/edge-ingestion-overhaul-3pr-plan.md](plans/edge-ingestion-overhaul-3pr-plan.md) — Edge Ingestion Overhaul — 3-PR Execution Plan — Status: Planning only. - [plans/tooling-activation-implementation-plan.md](plans/tooling-activation-implementation-plan.md) — Tooling activation — implementation plan — Status: local workstreams (WS-B, WS-C) implemented 2026-08-01; +## Full Deep audit remediation (2026-10-17) + +- [Remediation spec](audit/2026-10-17-full-deep-remediation-spec.md) — observed defects and acceptance expectations. +- [Parallel implementation plan](superpowers/plans/2026-10-17-full-deep-remediation.md) — six implementation lanes, dependencies and review gates. +- [Local implementation results](audit/2026-10-17-full-deep-remediation-results.md) — issue disposition, verification limits and operator handoff. +- [Clinical release handoff](governance/clinical-release-remediation-handoff-2026-10-17.md) — genuine outstanding privacy and clinical evidence. +- [Physiological calculator hold](governance/physiological-calculator-hold-2026-10-17.md) — containment pending sourced clinical review. +- [Cache freshness](rag-behaviour/cache-freshness.md) — durable revision fences and mixed-deployment limits. + ## Plans and workstreams (living) - [maturity-backlog-workorders.md](maturity-backlog-workorders.md) — actionable work orders tracking the repository-maturity audit backlog diff --git a/docs/agents/codex-cloud-legacy-bootstrap.md b/docs/agents/codex-cloud-legacy-bootstrap.md index 8dd49848d8..e0197d98f5 100644 --- a/docs/agents/codex-cloud-legacy-bootstrap.md +++ b/docs/agents/codex-cloud-legacy-bootstrap.md @@ -13,6 +13,30 @@ installer. The direct installer alone permits the documented inherited `GH_TOKEN`/`OPENAI_BASE_URL` exit `2` path; that remains a failed raw boundary. No cache, scoped profile or child scrub makes the raw parent clean. +## Optional Python 3.12 dependency lock + +The selected legacy bootstrap installs `worker/python/requirements-cloud.txt` +with `--require-hashes`. Its source is `worker/python/requirements-cloud.in`, +which includes the shared OCR requirements and constrains Cython to the range +required by quicksectx 0.4.1 (`>=0.25,<3.2.0`). Regenerate with Python 3.12: + +```bash +PYTHON_BIN=python3.12 npm run generate:worker-python-cloud-lock +``` + +The native generator uses pip-tools 7.6.0, resolves dependencies and emits hashes; +do not hand-edit versions or hashes in the generated lock. After an authorised, +coordinated regeneration, install that exact file in a fresh Python 3.12 venv +with `python -m pip install --require-hashes -r worker/python/requirements-cloud.txt`, +then require `python -m pip check` to succeed. A static lock-header check alone +does not prove dependency resolution or installation. + +The default worker Docker image uses Python 3.11 and +`worker/python/requirements.txt`, generated from `requirements.in`. An optional +Cloud-lock failure does not establish a default worker failure. Never install +the production lock on Python 3.12 as a substitute: medspaCy has different +spaCy constraints for the two Python versions. + ## Select one checkout and config home Setup binds its generated `~/.clinical-kb-codex-cloud.sh` and command shims to diff --git a/docs/audit/2026-10-17-full-deep-remediation-results.md b/docs/audit/2026-10-17-full-deep-remediation-results.md new file mode 100644 index 0000000000..011225a3b2 --- /dev/null +++ b/docs/audit/2026-10-17-full-deep-remediation-results.md @@ -0,0 +1,564 @@ +# Full Deep remediation: local implementation and acceptance + +This is an engineering handoff for the [remediation spec](2026-10-17-full-deep-remediation-spec.md) +and [parallel implementation plan](../superpowers/plans/2026-10-17-full-deep-remediation.md). +It is not a new audit score, canonical task ledger, clinical validation or deployment record. +The later explicitly approved staging-only default-grant repair is recorded below under +“Approved staging execution”; it changes no production state and does not clear release holds. + +Original baseline: `b0edabf0b73a806535aa3289e5de8023cf39997f`, branch `work`. Six isolated +implementation lanes were integrated into one uncommitted local change. Original audit findings +and evidence remain preserved. No provider mutation, publication, commit, merge or deployment +was performed. The draft migration must not reach `main` outside a specifically approved database +window: merging it applies the SQL to production automatically. + +## Original-tree continuation — 2026-10-18 + +The owner approved completing the outstanding task after the specific new-hash request. +The native append-only sealer added only +`20261017221129_audit_durable_recovery_pairing_and_cache_fences.sql`, with normalized SHA-256 +`f0667fed9f0294c1f5ba40252d11ee66e6de07be340a1ec789e0503a1192f2ac`. +All 317 previous entries are unchanged; the manifest now contains 318 seals. The actual +immutability guard and complete 10-case owning test file passed. This records draft bytes +locally and applies no SQL. The two initial migration-seal test failures are resolved. + +Later on 2026-10-18 the same bytes (`f0667fed…`) were renamed to +`20261018082910_audit_durable_recovery_pairing_and_cache_fences.sql`, so that the migration sorts +after main's newest migration, and resealed with `npm run migrations:reseal`. The owner approved +this at 16:17 AWST, relayed by Grok Bot, and confirmed option (b) at 16:33 AWST: reseal only the +reviewed bytes, reverting the unreviewed post-seal edit in `a62fc95418`. No SQL was applied. + +Three disposable, task-generated webpack caches were removed to recover approximately 6.6 GiB. +All 3,600 retained non-cache production build files have identical before/after hashes. Source, +dependencies, test evidence and the production artifact remain preserved. The one exact-image pull retry still failed after 208.4 seconds during Docker VFS layer +registration, despite 21,823,168,512 free bytes before retry. No usable image or container was +created and schema replay never started. Both failures remain recorded. Task UID 1000 cannot +inspect the root-owned daemon layer directory; the exact transient storage requirement is +unproven. At that checkpoint, a suitably provisioned isolated Docker environment was the identified route; no blind retry, driver +change, unrelated deletion or hand-edited drift manifest was used. + +Twelve of the original 13 unit failures are now addressed by complete affected-file passes; +one remains: actual drift-manifest freshness. The original +full-unit execution is preserved, not relabelled green. Installed-dependency parity and the +migration-role guard passed again. All 86 prior application/test/SQL fingerprints and the +retained runtime build are unchanged, so earlier browser/build/native checks remain attributable +evidence rather than newly executed tests. + +The same canonical local receipt has been refreshed with these new proofs and still reports +**Blocked**, not Completed: storage/replay and external clinical/privacy +conditions remain unmet. No external task synchronization occurred. + +The owner subsequently confirmed the read-only GitHub fetch under AGENTS.md:216. The exact +fetch succeeded and obtained genuine `origin/main` at +`54559adabe5b51396c2d1c0eb395f2cf79b43fbf`; the assessed HEAD and all 87 frozen implementation +paths remain unchanged. The complete package-parity owning file passed all 18 tests, and the +default RAG package/receipt, ledger, diff-integrity and mockup checks passed. Independent source, +log and fingerprint review confirmed this closes the missing-ref comparison condition. + +Fetched main is 18 commits ahead of the assessed HEAD; no merge or rebase was performed. The +separate RAG programme publication/P00 requirement to reconcile its pinned base against current +main remains applicable before executing that programme. These snapshot checks establish no +publication readiness or verification of those 18 later commits. No live database operation, +paid evaluation, clinical approval or deployment was performed. The following original-handoff +sections retain their dated execution history; this continuation records subsequent status changes. + +## Current-main candidate — 2026-10-18 + +The owner subsequently requested current-main reconciliation. A separate candidate at +`/workspace/psychsift-remediation/current-main` starts from genuine main +`54559adabe5b51396c2d1c0eb395f2cf79b43fbf`; the original `/workspace/PsychSift` tree at +`b0edabf0b73a806535aa3289e5de8023cf39997f` and its 87 frozen implementation paths remain +preserved. The 103-path remediation patch was applied to this candidate, with four text conflicts +and the additional calculator-page test mismatch resolved. No commit, publication, merge, +deployment or live SQL followed. The earlier original-tree counts remain historical evidence. + +The integration keeps main's stable favourites callbacks and record-open throttling alongside +the remediation's account/epoch fences. Main's owner decision to hide the entire physiological +panel on the calculators page is preserved; the retained component separately enforces its +clinical hold. Main's unsigned therapy/DID restrictions remain. The native static manifest +regenerated with exactly eight Factsheet content/lineage changes, without changing their +eligibility/status fields. Independent source review found no concrete counterexample in these +bounded overlaps; it is separate from runtime proof or clinical validation. + +The seal union now contains **319 entries**: all 318 genuine-main entries and all 318 prior +remediation entries are preserved, with zero existing hash changes. The only remediation draft +still has the previously approved +`f0667fed9f0294c1f5ba40252d11ee66e6de07be340a1ec789e0503a1192f2ac` hash. Main's teaching +null-creator revoke guard remains intact. The native append-only sealer carried forward the +approved draft entry; this was no reseal or live SQL application. Earlier 318-entry results describe +the original tree, not this union. + +AD01 is locally resolved **in this candidate only** by genuine main's existing `argparse` 2.0.1 +override; neither the override nor its lock was invented by this remediation. Actual installed +Mammoth 1.13.0/argparse 2.0.1 checks passed all **13 cases**: five CLI help/error cases, six real +CLI conversions, one library raw-text case and one production dependency-closure case. A fresh +production-only npm audit reported **zero vulnerabilities**. This supersedes the earlier +no-compatible-fix finding for this dependency graph; it does not assert the original preserved +installation was changed or that audit output proves every security property. + +The fresh complete unit/DOM execution recorded **32,414 passed, three failed and two skipped +out of 32,419**, in 820.298 seconds. Two failures correctly detected host npm 11.9.0 where the +new main Cloud contract requires 11.17.0; the third is the known stale drift manifest. After +installing genuine integrity-verified npm 11.17.0 into a private runtime prefix, exact-lock offline +installation and full installed-tree parity passed. The complete Cloud platform owning file +then passed **22/22** through its supported `CLOUD_PLATFORM_NPM_HOME` route; no fake version +wrapper or test/source weakening was used. The 13-case installed Mammoth proof passed again +after that installation. Required package versions stayed unchanged; seven optional WASM-only +locations were omitted by the corrected npm installation. The raw full-suite result remains +failed. Complete owning-file followthrough now addresses all three failures: Cloud 22/22 resolves +the runtime cases and drift detection 21/21 follows actual manifest regeneration below. This is +not a fresh green full-suite rerun. + +Independent provenance review found that the installed Mammoth probe's local `npm ls` +subprocess resolved the ambient npm 11.9.0, although installation/parity and the Cloud +followthrough used genuine 11.17.0. Its update notifier attempted a registry lookup that +failed with `EAI_AGAIN`; this probe is therefore not described as making zero network +attempts. The observed installed dependency closure and real CLI/library outputs remain +valid. The private review preserves this distinction and the failed lookup evidence. + +Repository lint passed in 139.382 seconds and the nonincremental source typecheck passed in +102.611 seconds. A fresh isolated production build completed, but the first browser attempt +failed before test cases because the private global-setup harness loaded an ES module through +an incompatible transformed import (`exports is not defined in ES module scope`). Only that +private harness was corrected. The retry reused the same build: all **3,584 non-cache artifact +files** retain identical hashes. The complete selected production Chromium `ui-tools.spec.ts` +and `ui-smoke.spec.ts` owners then passed **217/217**, with zero failures, skips or flaky cases, +in 363.291 seconds for the wrapper (360.299 seconds in the Playwright report). The existing +quarantine/mockup exclusions remained explicit. `ensure` ran and the actual production +`/api/local-project-id` identity was verified before browser journeys. The production client +bundle secret-marker check also passed. This remains local custom-Chromium evidence, not +hosted deployment, physical Safari/PWA or an unselected browser-matrix run. + +The prior Docker/VFS capacity failures remain real historical results. The same-image rootless +route then verified the exact OCI index/child/config, all 29 compressed layers and ordered +uncompressed diff IDs, and trusted signed Debian/package umoci provenance. Actual native image +bootstrap plus genuine main's complete schema produced **zero catalog differences** from its +committed expected snapshot. This baseline proof preceded implementation of the optional backend. + +The maintained native generator's explicit rootless backend now passed against the integrated +schema in **42.420 seconds**. It uses the original pinned image, native initialization, postgres +role bootstrap, dynamically discovered Storage owner, complete schema replay and actual catalog +capture. The emitted manifest SHA-256 is +`06bf813db741c9adb7bcbf52fc1545b3541944deee47ff094f3876be6d8fd280`, bound in private evidence to +schema hash `5a095aebcb31d373f9ab80243a93ce47dd8546e136d4f154a7961f73453bff1e`. +Its actual **335-function** snapshot equals the independently captured complete integrated schema +mirror. Guest pg_ctl/server cleanup returned **0/0**, supervisor exit was **0**, no forced stop +occurred, and the owned disposable run was removed. Only then was the manifest atomically +published locally. Docker remains the default; generator/image identity and strict freshness +fields remain unchanged. No hash-only catalog edit, fabricated snapshot or broadened allowlist +was used. **The local full-catalog and stale-manifest blocker is closed.** + +The first maintained native attempt did replay roles/scaffold/full schema but failed removing +readonly Nix directories; it left the old manifest unchanged. That failed attempt remains recorded. +Cleanup now validates owned directory identity and makes only owned directory file descriptors +removable, without following external symlinks. The preceding focused test run recorded +**134 passed, three failed**: known stale drift plus two new fixture mode expectations affected +by the restrictive host umask. Explicit fixture chmod establishes the intended exact modes; +no application assertion or safety control was weakened. After actual manifest regeneration, +the complete four owning files passed **137/137**: 82 CI cache safety, 21 drift detection, +eight deep freshness and 26 rootless backend cases. New post-backend repository lint passed +in **2.909 seconds**, and migration-role and organisation checks passed. The final nonincremental +post-backend typecheck passed in **101.327 seconds**. Its preceding failure caught the project +ProcessEnv requirement for NODE_ENV and optional process-handle typing; the corrected minimal +host-tool environments and directly typed handle passed. The sandbox still clears the inherited +environment and retains the original image environment. A final actual replay of this exact source +passed in 42.420 seconds; the earlier accepted 38.459-second manifest is preserved privately. +Only generation time and replay duration differ between those accepted catalogs. The final four +owning files passed **137/137** again after the type correction; earlier failed checks remain recorded. + +Five unchanged maintained SQL files also passed against the full same-image schema mirror, +including actual 200,000-row limits and two-user privacy checks. The private harness used the +native SQL runner's documented Auth shim and one rollback transaction per test. A separate fresh +manual replay passed **all 319 unchanged sealed migrations**, one transaction per file, including +the exact approved f066 draft. The earlier autocommit-harness attempt stopped at file 304 when +its ON COMMIT DROP preimage vanished; it was diagnosed as a transaction-contract mismatch and +retained, rather than editing or skipping immutable SQL. The native strict chain/mirror comparator +reported **No divergence**. The same-image comparison left its existing pg_net allowance unused; +no allowance was changed to make it pass. + +Actual effective-privilege queries verified all eight audited functions are owned by postgres, +with **16 effective anon/authenticated EXECUTE denials and eight service_role grants**. The +SQL waves also closed with graceful pg_ctl/server **0/0**. These runs were not warning-free: +the corrected chain emitted **14 already-in-transaction** and **14 no-transaction-in-progress** +warnings where existing BEGIN/COMMIT files interact with per-file wrappers, with zero SET LOCAL +outside-transaction warnings. Two immutable platform-role warnings also remain: postgres cannot +alter the reserved platform role's default privileges or assume that role for its probe, so the +probe was skipped. **Operator verification of the reserved role's default-privilege posture +remains open.** Passing actual function ACLs and two-user privacy tests do not supply that proof. + +Independent reviews challenged the backend, baseline catalog and SQL evidence. This is actual +local schema replay, manual same-image migration execution and a native strict comparator; +it is not Supabase CLI/emulator execution, Auth HTTP, a real Storage service, hosted deployment +or production acceptance. The maintained native generator receipt is +`controller/current-main-rootless-native-manifest-cleanup-corrected.json`; its bound private +provenance is `rootless-replay/native-evidence/integrated-manifest-cleanup-corrected.json`. +The complete SQL warning/failure qualifications remain in `rootless-replay/sql-wave-report.md`. + +The current-main execution receipts, raw JSON/logs, seal union, artifact fingerprints and +independent review are retained under the session-local `/workspace/remediation-results/` +`controller/` and `current-main/` directories; rootless provenance is under `rootless-replay/`. +This update is not a new audit score, readiness verdict or task-receipt export. Local acceptance +remains **Blocked** by the retained external conditions and operator proof gap: four privacy/provider +obligations, qualified clinical review, authorised provider evaluation, hosted operational +acceptance and reserved-platform-role default privileges. Calculator and real-staff holds remain. RAG programme history/reconciledBase and +accepted receipts were not reset; materializing this remediation on current main does not +establish fresh formal programme/P00 acceptance or authorise its execution. + +## Final acceptance and bounded staging investigation — 2026-10-18 + +The subsequent complete offline unit/DOM run used genuine npm 11.17.0 and the final actual +catalog. It recorded **32,442 passed, one failed and two skipped out of 32,445**, in +814.648 seconds. The remaining failure was a stale source-shape assertion in +`tests/hosted-migration-role-guard.test.ts`: it expected the former Docker-local +`psql(storageSchemaOwner, scaffoldSql)` call after the shared replay had changed to +`await sql(storageSchemaOwner, inputs.scaffoldSql)`. Only that expectation changed. Dynamic +owner discovery, reserved-role rejection, the immutable historical hash and both replay +transports remain intact. The complete guard and rootless owning files then passed **45/45** +(19 plus 26), and the actual migration-role check passed. This resolves the observed final +failure; it does not relabel the raw complete-suite execution green or assert another full rerun. +Prior application/build/catalog evidence is reused only within its unchanged source scope. + +Newly available connectors permitted bounded read-only inspection of Psych Sift Staging +(`ikoiolksxqxfxgiyqpnu`) and the PsychSift Railway staging environment. No credential values, +clinical records or document contents were read. The current app deployment is +`62558e5c7cb6fa2cbc12944093d0d053240cb351`, distinct from this uncommitted candidate. An +existing staging worker was already building; this task neither started nor stopped it. That +state does not meet the isolated tenancy harness's no-worker precondition. Executor provider +credentials and direct HTTP permission are absent; the passive one-hour response-time read +returned no samples. Hosted candidate Auth/Storage, provider-backed RAG, cache/performance +and rollout acceptance therefore remain unverified. Later remote-main changes are not silently +included in the pinned `54559ad` reconciliation. + +**SF01 — Staging future-default ACL contract gap (confirmed operational finding, P2).** +For future public objects created by `postgres`, effective defaults deny browser roles but also +lack the required service-role table CRUD, sequence USAGE/SELECT and function EXECUTE. +For the reserved creator, effective defaults grant broad table/sequence/function privileges to +browser roles. Raw catalogs confirm explicit public grants plus built-in global PUBLIC function +EXECUTE. This is evidence about future-object defaults, not proof of existing-object exposure, +production state, a service outage, or a reachable exploit. The connector is non-superuser +`postgres` and cannot administer the reserved creator. Browser roles cannot assume or inherit +`postgres` or `service_role` at the observed time. + +A separate, private `staging-postgres-defaults-proposal.sql` restores only the seven required +service privileges on future public objects created by postgres. It contains role/ACL guards, +bounded transaction timeouts and postconditions. A real disposable PostgreSQL 17.6 proof ran +under a nonsuperuser active postgres role: all 45 individual privilege assertions, actual service +CRUD/identity/sequence/function controls, browser operation denials, intended negative guard +failures, mid-transaction rollback, idempotence and unchanged existing/global/non-public/other- +creator sentinels passed. Its owned server stopped successfully and was confirmed stopped. +This synthetic proof does not establish hosted execution or RLS/API acceptance. Independent +source review found no blocking defect for the observed baseline; target binding is an external +operator prerequisite. **The proposal has not been applied.** + +The separate reserved-creator repair needs platform authority and a compatibility decision: +a global PUBLIC function revoke affects future functions in every schema and cannot be +replaced by a public-only subtraction. No historical migration was edited/resealed, no reserved +role was granted/assumed, and no live SQL/settings change was made. Exact staging approval +for the postgres proposal would not authorize that broader platform correction or production. + +The Notion research/documentation skill was used for bounded project-page discovery. The +two retrieved PsychSift planning pages supplied no qualifying clinical, legal, privacy or provider +approval. This is not proof that no approval exists elsewhere. Existing four privacy/provider +conditions and qualified clinical/staff activation requirements remain pending; safe holds stay. +Cloud runtime and systematic-debugging guidance supported the executed verification; +Supabase and security-fix guidance supported catalog interpretation and the independently +reviewed draft. The security skill's supplemental artifact reference was unavailable after bounded +lookup; existing private artifact conventions were retained. No external task synchronization, +support message, publication, provider spending, merge or deployment occurred. + +The private `final-acceptance/` directory contains raw suite output, the 45-case follow-through, +read-only staging catalogs/inventory, exact draft SQL/hash, local proof and independent reviews. +The canonical handoff preserves all 19 original issue IDs and records SF01 separately as a new +staging operational finding. No new quality score is calculated. Overall release acceptance +remains **Blocked** by genuine approval conditions, with hosted and platform conditions visible. + +### Approved staging execution — 2026-10-18 + +The owner answered “Yes approve” to the exact postgres-only SQL proposal and named staging +target. Project metadata freshly confirmed **Psych Sift Staging** (`ikoiolksxqxfxgiyqpnu`), +PostgreSQL engine 17 / Supabase version 17.6.1.147. The approved file's SHA-256 remained +`7226caf7e7ab68c0746cbfd58dab11537fdbd692e51c3aa6bb454f577058dbbd`. +Its bytes, including the original draft comments, were preserved; the separate approval record +supplies execution authority. Fresh preflight rechecked active/session postgres, absence of +reserved-role administration, client role membership and the exact missing-service baseline. + +The exact transaction was then executed through the PsychSift account connector against the +explicit staging ref, without a migration/history operation. The SQL operation succeeded. +A separate post-commit catalog read returned **Passed**, with all **eight checks true**: +identity and client roles unchanged; all global/non-public/other-creator defaults unchanged; +existing object ACL/RLS/owner metadata unchanged; browser future privileges denied; exact +required service defaults present; reserved-creator defaults unchanged; and the complete +18-row effective-default matrix retained. The existing-object metadata comparison covered +986 catalog entries with an unchanged fingerprint. No patient/document data was read, and +no hosted synthetic objects were created. + +For future public objects created by postgres, service_role now receives exactly table +SELECT/INSERT/UPDATE/DELETE, sequence USAGE/SELECT and function EXECUTE; anon and +authenticated receive none. This closes **SF01 Part A** at the hosted default-catalog layer. +It supersedes the earlier “not applied” status for that part only. The reserved creator's broad +defaults are deliberately unchanged: **SF01 Part B remains open**, requiring platform authority +and a reviewed global/non-public compatibility decision. Existing-object/application/Auth/Storage +behavior is not established by these default-ACL checks. Clinical/privacy holds, provider-backed +RAG and candidate-matched hosted acceptance remain unchanged. Production was not touched. + +Approval, exact query/hash, preflight, raw execution result and post-commit evidence are in +the private `final-acceptance/approved-staging-execution/` directory. The local task receipt +and canonical handoff distinguish this approved staging mutation from absent production, +reserved-role, migration, merge and deployment operations. + +### Rapid PR follow-through — 2026-10-18 + +The owner requested parallel resolution of the remaining conditions. Three bounded specialists +investigated CI, dependencies and hosted/platform evidence; a fourth independently challenged +the consequential claims and integration. Requested routing was gpt-6.1-sol/high; execution +telemetry was unavailable. The lead remained the only integration writer and check owner. + +The live PR had independently advanced to `f70929e5306413bab2f90348fb68f9ddec8cf450` +and was ready for review rather than draft. This continuation preserved those concurrent changes +by fast-forwarding the clean local branch. Those changes include the unchanged-byte rename of +the audit draft from version `20261017221129` to `20261018082910`; the earlier filename and +seal-union counts in this report remain dated history. No migration, seal, schema or catalog +manifest was changed by this continuation's integration with fetched main +`807ce0cb2b8f0850e6c41f04b0b00e856bd585fb`. One clean merge incorporates its five later +commits, including worker container health configuration and verification/hook improvements. + +At f70929e, all 40 observed checks were complete: 28 succeeded, ten were skipped, one was +neutral and **Supabase Preview failed**. CI run `39115639624` passed its selected unit coverage, +lint/types, build, container, local migration/schema/manifest, Chromium UI and offline RAG jobs; +PR-policy checks also passed. It tested synthetic merge `fbda6cc51193de86dd7c6f8c85d5a38b8fa1bd06` +against base `9ba5b29008d3122b0d6fd817fc8ebf0b0110e1ad`, not the new main integration. +After integration, all **314/314** selected complete owning tests and **15/15** Codex hook +fixture tests passed locally. New-head hosted checks must be observed separately; earlier +green results do not establish them. Full browser/device and live provider acceptance remain open. + +The Preview check reported `Remote migration versions not found in local migrations directory.` +Read-only inventory identifies PR 3640's preview branch `d16a8c2f-cc76-4263-a367-7efcbbd9e907`, +project `enkvvwbeyhkbzavburcq`, with status MIGRATIONS_FAILED. Comparing the complete +322-version sets identifies only the old timestamp `20261017221129` remotely and only the new +timestamp `20261018082910` locally. The old entry records 39 statements. This explains the +history-alignment failure; it does not prove remote SQL bytes or schema equivalence. Supabase's +documented delete/recreate recovery is prepared for this exact PR preview, but **not executed**: +it deletes preview-only state and can incur compute cost. `with_data=false` is its creation profile, +not proof that no valuable state exists now. Exact owner approval must bind the final candidate, +branch and preview project, exclude Production and dedicated Staging, and preserve evidence. +No history repair, placeholder migration, reseal or production workaround was performed. + +The dependency investigator confirmed **GHSA-vfj7-8cjw-p6xm / CVE-2026-93687**, high, affects +`braces` through 3.0.3; the official registry and reviewed advisory provide **no fixed release**. +It is development-only through Next's ESLint plugin, fast-glob and micromatch. No application/ +worker import or identified clinical-input route was found; this limits demonstrated reachability +but does not fix or dismiss the advisory. The existing framework-modernization checklist already +records the residual. No invented version, incompatible brace-expansion alias or weakened lint +gate was introduced. A fallback through the existing authorized Composio GitHub connection +subsequently identified both open high alerts: **#24** and **#27**, both NLTK 3.10.3 in the +two Python requirements files, **GHSA-8mgp-746c-j5xp / CVE-2026-81726**. The advisory has +no patched version and official PyPI still publishes 3.10.3 as latest. Static repository and +installed medspaCy/QuickUMLS tracing found no application call to the affected model-path +persistence APIs; that limits demonstrated reachability, but leaves both vulnerable dependency +alerts open. These are distinct from the braces advisory. Deno npm advisory lookup returned +none; the broader Python advisory lookup was blocked by the proxy, not passed. + +The new-head CI run `39117859413` exposed a real Advisory UI test failure at +`tests/ui-tools-search-mode-mockup.spec.ts`: the immediate result count was zero before +the first result appeared. The shared setup waits for the shell and input, not the result rows. +The test-only correction explicitly waits for the first result before retaining both the +positive-count and exact reported-count assertions. The precise hydration timing cause remains +unproven; the separate asynchronous universal-search component does not populate these rows. +Independent source review accepted the correction without weakening assertions. With retries +disabled, the failed case passed **5/5** repetitions and the complete owning spec passed +**16/16**, using the local offline Next development server and custom Chromium executable. +This is local browser evidence, not production-build, other-browser or new-head CI evidence. +The owned development listener was stopped and its port confirmed closed after verification. + +Fresh staging catalogs confirm the previously approved postgres default repair remains effective. +Reserved-creator defaults remain broad, and the connector is still non-superuser postgres without +authority to administer that creator. A platform-support handoff with explicit global/non-public +compatibility questions is prepared but unsent. No new live database operation was performed. + +The observed staging app deployed `675fec3051d700942072dc1675549411c53fe51c`, not this PR; +its existing worker remains live. Executor credentials/target HTTP are unavailable. The hosted +tenancy harness also changes both synthetic users' administrator metadata and writes CME, +On Call and roster fixtures; those effects need an explicitly scoped execution plan. The live +canary workflow targets trusted-default-branch production identity, so dispatching it would not +verify an arbitrary unmerged staging candidate. Generic 37ms service telemetry is not endpoint- +specific authenticated load evidence. Candidate-matched Auth/Storage/RAG/cache/performance/ +rollback acceptance therefore remains unverified. Four privacy/provider/legal conditions and +qualified physiological/staff approvals remain pending; no holds or approval records were cleared. + +Private evidence is retained in `rapid-followthrough/`: exact CI snapshot and decisive excerpts, +advisory metadata/lock exposure, read-only hosted inventory/history, owning-test JSON and +independent challenge. The same task identity and historical score are preserved. This wave +resolves fetched-main reconciliation and local integration proof; preview recovery, platform +authority, genuine approvals, unknown alert identity and actual hosted acceptance remain visible. + +## Issue disposition + +“Implemented” below means local source and regressions exist. Acceptance checks and operational +conditions are listed separately; it never means deployed or clinically approved. + +| Audit ID | Local implementation or retained condition | Acceptance boundary | +| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| F01 | Account favourites reject stale success, errors, 401 and rollback after identity change. | Actual DOM races and same-account controls; no claim about deployed sessions. | +| F02 | CME removal atomically records exact-object cleanup with the tombstone; retries reuse the obligation. New caller uses versioned RPC. | Draft SQL, full local schema/manual-chain replay and isolated transaction/rollback tests; real Storage service and hosted acceptance remain pending. | +| F03 | Both recovery CLIs share current-lease snapshot/CAS and one atomic recovery RPC. They preserve served artifacts and refuse managed-generation recovery. | Actual two-session PostgreSQL claim/recovery tests plus CLI/helper tests. Old recovery binaries must not be run after SQL deployment. | +| F04 | Roster identifies failed rotation/course-booking sources and retries only the selected failed source. | DOM and hook tests preserve successful data. | +| F05 | Upload and fatal-worker diagnostics use fixed classifications and opaque object references. Duplicate cleanup failure is awaited and safely caught. | Actual POST and fatal-worker regressions inspect logs/webhook payloads using invented data. Exact object paths remain in the scoped cleanup queue. | +| F06 | Existing four pending/partial privacy release conditions are retained with a concrete evidence handoff. | **External blocker retained.** No engineering test supplies legal/provider approval; see the [handoff](../governance/clinical-release-remediation-handoff-2026-10-17.md). | +| F07 | Physiological bedside tools are withheld pending sourced, accountable clinical review. Dedicated pending inventory and drift contracts added; psychometric tools remain available. | **Contained, not medically validated.** See the [calculator hold](../governance/physiological-calculator-hold-2026-10-17.md). | +| F08 | Worker rebuild selection includes the model artifact manifest and verifier. | Isolated watch-selection tests; hosted deploy selection not observed. | +| F09 | Optional Python 3.12 cloud input/lock resolves compatible Cython and Thinc dependencies. | Native generated lock, clean hash-enforced install, `pip check` and full 61-test native suite passed. Canonical Python 3.11 lock unchanged. | +| F10 | Mounted Roster state and pending destructive actions are invalidated on account transition and unmount. | DOM tests cover mounted A→B and legitimate same-account undo/save; already-dispatched A requests cannot be undone by this guard. | +| F11 | Pay requests both complete fortnights, handles history failures/loading and accounts for UTC read boundaries. | Actual component prefill and boundary tests; advisory hours only, no payroll transfer. | +| F12 | Open Shifts, cover and Teaching conversions use selected-zone helpers; unchanged Teaching times preserve their instant across ambiguous DST. | Perth/Sydney/fractional-zone and skipped-time tests. No physical-device claim. | +| F13 | Fresh durable scope revisions fence answer/search caches, late writes, shared reads and in-flight coalescing. Same-process epochs remain part of the fence. Missing RPC disables reuse. | Cache/entrypoint regressions, actual scope RPC and ACL probes. Old replicas must be drained; hosted scope-hash cost remains unmeasured. | +| F14 | Teaching locks pairing authority before dependent log/confirmation/note changes, using parent-before-child order. | Actual PostgreSQL registrar-edit/supervisor-confirmation orderings plus full local schema/manual-chain replay; hosted acceptance remains separate. | +| F15 | The real-staff hospital sick endpoint refuses before creating a reader/admin client when its existing release flag is held. Enabled requests retain auth/rate/role/hospital checks. | Held/enabled route and actual role-reader tests; enabling remains an approval decision. | +| F16 | SLO runbook names all 12 current alert codes, exact thresholds/filters and operational responses. | Contract tests and source comparison; configured alert delivery is not observed delivery. | + +Additional audit leads were resolved within these changes: HY01/HY04 account consumers gained +request fences; HY02 diagnostic prose paths were removed; HY03 Factsheet projections preserve +demo-use/content-month context without changing eligibility; HY05 team moves revoke whole-team +authority transactionally and reject stale grants; HY06 pairing/confirmation orderings are +serialized. HY07 deployed proxy/cache assumptions remain unverified. At the original baseline, the transitive CLI-only +sprintf advisory AD01 had no compatible upstream resolution in that installed Mammoth range; +no application exploit was established and no unsupported override was introduced. The current-main +candidate resolution and its actual installed proof are recorded above. + +## Additional defects found during acceptance + +- **RF01 — Defunct coordinator owners:** a terminated check left a Linux zombie holding a lease because `kill(pid, 0)` succeeds for unreaped processes. The coordinator now recognizes a terminal process only with evidence that no other threads remain; live/unknown cases stay conservative. Real process fixtures and existing sharing/token checks verify normal admission, with no forced lock deletion. +- **RF02 — Node startup code in the Edge graph:** local `npm run ensure` returned 500 because a negative runtime early return did not stop webpack resolving the Node-only web-push dependency. An explicit positive Node runtime branch now encloses all Node startup imports; production boot guards remain intact. Actual installed-webpack Edge/Node graph regressions accompany the startup retry. +- **RF03 — Sentry conditional export compatibility:** the installed SDK's Node entry exports the conversation convenience setter, while its Edge entry omits it. The wrapper now uses the public isolation-scope setter exposed by both entries and rejects non-UUID syntax. This is not a claim that Node monitoring was broken, or that UUID syntax alone establishes an identifier's provenance. Actual SDK scope tests run without initializing transports. + +## Verification and independent review + +The controller maintains the exact sanitized execution register, commands, logs, frozen patches, +content hashes and review reports outside the repository at `/workspace/remediation-results/`. +This path is session-local and is not a hosted evidence service. The final exported handoff must +carry those artifacts if another environment needs to reproduce the results. + +Independent reviews covered account fences, workflow/time boundaries, clinical holds, +cache/coalescing/RPC scope, database/application compatibility and diagnostic minimization. +Reviewers inspected underlying source and negative/positive controls. Review found and corrected +additional in-flight epoch, UTC-boundary and upload error-prose cases. Agreement is not counted +as separate runtime execution. + +Fresh isolated PostgreSQL 17.6 checks passed all 17 scenarios, including execution of the draft +RPC revoke/grant statements against synthetic `anon`, `authenticated` and `service_role` roles. +These use actual current function bodies with reduced fixture dependencies. Two fixture errors +were diagnosed and corrected: a missing parent primary key and Node's truncation of database +microseconds. Failed attempts remain in the evidence register. This is not full-chain migration, +Supabase RLS, real Storage or deployed service-role verification. The owned server was stopped. + +Integrated broad/static/build/browser results are recorded in the final local execution register. +A passing targeted test is never represented as a passing full suite. Missing comparison refs, +provider authorization, supported configurations and runtime evidence remain explicit limitations. + +## Original-tree broad acceptance results + +The complete selected unit/DOM run executed 32,186 cases: **32,171 passed, 13 failed, +and 2 were skipped**. The raw JSON and logs are retained. Failures were traced to omitted +revision-RPC fixtures, a calculator expectation predating the intentional hold, a moved source +anchor, a stale drift manifest, the unsealed new migration and the unavailable genuine +`origin/main` reference. Followthrough runs are separate evidence; they do not rewrite that +full-suite result or establish a green full rerun. + +The corrected nonincremental TypeScript check, repository lint, worker build, RAG fixture gate, +Python-lock static gate and all-changed-file format check passed. The static readiness script +passed with a public project-URL configuration fixture and 13 warnings; it made no provider +request and establishes no connected operational readiness. The actual privacy release gate +continues to fail on its four pending/partial obligations. + +The calculator followthrough retains the original renderer/input assertions under an isolated, +explicitly synthetic review fixture, adds a real-default release-hold assertion, and passed all +17 owning-file cases. An independent reviewer verified that production governance is unchanged. +The complete clinical source-anchor file also passed after its one moved link was corrected. + +The complete three-file RAG fixture followthrough passed **196/196** cases. It first proves +actual local answer/search hits with explicit, faithful fixtures, then verifies governance-change +rejection and all six expected read fences. An independent reviewer accepted the assertions. +Nine of the original 13 unit failures are addressed by complete affected-file passes. Four +remain blocked: drift-manifest freshness, two new-migration sealing cases and genuine +`origin/main` comparison. No unchanged full suite was rerun merely to replace its recorded result. + +The isolated production build completed after one diagnosed environment correction: Next's +TypeScript child exhausted its default 4 GB heap, while the repository's normal production build +already supplies inherited `NODE_OPTIONS`. Retrying with that same 8 GB setting completed the +unchanged build and type checking. The running artifact returned the expected project identity +and its actual client bundle passed the repository secret-marker gate. The selected **765-test** +production Chromium suite completed: **764 passed, one skipped, zero failures**. The maintained +iPhone/iPad install-hint case skips this desktop project by platform condition; its underlying +iOS behavior remains unverified here. Both new phone/desktop calculator-hold journeys passed. +The corrected pipeline took 1,468.3 seconds; Playwright reported 21.6 minutes for the tests. +The owned production listener and active browser processes stopped after completion. This uses custom Chromium +153.0.8010.0, explicitly distinguished from Playwright's pinned browser revision. + +At the original handoff, genuine local Supabase catalog replay was blocked: the exact pinned image downloaded from the workspace-permitted Docker registry but +failed registration with `no space left on device` under Docker's VFS storage driver. No usable +image, replay container or manifest was created. Unrelated caches and Docker configuration were +preserved. Reduced PostgreSQL proofs do not close this full-catalog condition. + +## Acceptance-stage limits + +At the original handoff, the default main-branch comparison checks could not resolve +`origin/main` in that checkout. Snapshot +checks against the recorded baseline pass without creating a substitute remote ref. Existing +sealed hashes for all 317 previous migrations are unchanged. The new draft was unsealed at the initial handoff; the 2026-10-18 continuation above records its +specific approval and successful append-only seal. No applied migration or existing hash is rewritten. + +Cache changes preserve scoring/comparator order. The Factsheet context correction changes eight +retrieval text hashes; any required provider-backed before/after evaluation evidence remains +unrun and is a deployment acceptance condition, not implied by the offline tests. + +## Remaining work and owners + +| Owner role | Finding or condition | Next action and acceptance | +| ---------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Repository/database owner | New draft seal | **Completed 2026-10-18:** exact approved entry preserved; current-main union has 319 seals with no old hash changes. Original-tree 318-entry guard and all 10 owning tests passed. This authorizes no SQL application. | +| Database/platform maintainer | F02/F03/F13/F14, HY05/HY06 | Local catalog generation, 319-file manual chain, strict parity and five SQL files passed. Retain operator proof for skipped reserved-role default privileges, actual service/Storage and hosted acceptance. No hash-only manifest update or weakened allowlist. | +| Privacy/service owners | F06/F15 | Supply the four genuine provider/privacy decisions and the covered staff-service release decision. The actual release validator must accept the evidence; retain held paths until then. | +| Qualified clinical lead | F07 | Establish authoritative formula/source mappings, representative clinical evaluations and accountable dedicated review. Engineering arithmetic and synthetic test fixtures do not authorize restoring bedside advice. | +| RAG/platform maintainer | F13, HY03/HY07 | Obtain authority for the provider-backed evaluation pair covering eight text-hash changes; measure scoped revision hashing and complete hit/join paths on representative authorized data; verify deployed proxy/cache assumptions. Drain old replicas and incompatible recovery clients within the approved rollout window. | +| Repository maintainer | Genuine comparison ref; AD01 | **Current-main candidate prepared:** genuine 54559ad base; original tree preserved. AD01 locally resolved by existing main override, 13 actual installed cases and zero-vulnerability production audit. Formal programme/publication and hosted conditions remain separate. | + +After storage/access/authority is supplied, allow roughly 1–3 hours of engineering verification +for the combined local gates and isolated integration scenarios, assuming reproducible setup and +no new failures. Contractual, provider and clinical review lead times are unknown and excluded. +Only explicitly marked completed conditions in this table are closed by this handoff. + +## Required operator sequence + +1. Complete local acceptance and independent review on the exact final content, then obtain + ordinary publication authority separately if desired. Preserve the draft migration's merge gate. +2. Before deployment, obtain specific database-window approval and test full migration replay, + Storage/RLS behavior and production-role compatibility in an authorized isolated Supabase setup. +3. Stop old recovery CLI executions. Disable legacy RAG cache TTLs and drain old application replicas + and in-flight requests before relying on revocation. Prefer SQL first, then the matching application; + app-first missing RPCs intentionally refuse mutations or disable cache reuse. +4. Measure complete-scope hashing on representative authorized data. Typical eligible local answer + hits now need three revision RPCs and joined waiters two. Keep caches disabled if capacity or + revocation evidence is insufficient. Do not roll back to an unfenced recovery path. +5. Validate deployed artifact identity, health, bounded account/owner transitions, source revocation, + cleanup-worker completion, alerts and migration drift/history. Local evidence cannot replace them. +6. Obtain the actual privacy/provider/legal decisions and named clinical review before any covered + clinical production activation. Do not remove the calculator/staff holds to make acceptance pass. + +Canonical task reconciliation remains unavailable/unperformed. A sanitized local receipt reuses +`psychsift-repository-audit-2026-10-17-b0edabf0`; local completion, user acceptance, publication, +release approval and deployment remain distinct. + +## Original-tree final local handoff status + +This section records the original-tree export; the current-main candidate results above are +a subsequent snapshot. All four planned review waves are complete. Local implementation and the available selected +verification are recorded; **local acceptance and clinical production readiness remain Blocked** +by the explicit conditions above. Completing this engineering handoff does not close those +conditions or assert that every original issue is fully resolved. + +The local-only canonical receipt exported successfully with status **Blocked**. Its stable task +identity is unchanged, and no external synchronization occurred. The final machine-readable +handoff validates all 19 issue records, evidence links, 87 frozen implementation paths, actual +test counts and the append-only approved migration seal. The private source patch preserves the full +uncommitted diff. The final production pipeline ran for 24.5 minutes, including build/type +checking; the test phase reported 21.6 minutes. Four review waves were planned and completed, +with no explicit user time cap. Earlier audit time is excluded from these verification figures. diff --git a/docs/audit/2026-10-17-full-deep-remediation-spec.md b/docs/audit/2026-10-17-full-deep-remediation-spec.md new file mode 100644 index 0000000000..1daa806417 --- /dev/null +++ b/docs/audit/2026-10-17-full-deep-remediation-spec.md @@ -0,0 +1,181 @@ +# Full Deep audit remediation specification + +Baseline: `b0edabf0b73a806535aa3289e5de8023cf39997f`. User authorised implementation after the audit; audit observations are not medical/legal approvals. The original audit remains preserved outside the repository. + +## Required outcomes + +### F01: Delayed favourites save restores previous identity state + +R03 requires epoch checked before committing user-scoped state. + +Real provider probe: delayed account-A save completes after sign-out or B reset; both render saved instead of empty. Unkeyed root provider remains mounted. + +Acceptance: Both audit scenarios stay empty for B/signed-out; A late failure cannot expire B; Queue/clear/structured mutation ordering still works. + +Boundary: GET abort race and old set metadata consequences remain hypotheses, not independently confirmed. + +### F02: CME evidence removal can strand a private stored file + +R06 requires durable object deletion or cleanup scheduling after privacy removal. + +RPC tombstones and clears filename; deletion/outbox insertion occurs later in application. Postcommit response loss leaves zero cleanup jobs; retry rejects removed_at. + +Acceptance: Inject lost response/process death after commit; Exact storage object remains durably queued until deleted; Retry and cleanup-worker replay succeed without resurrecting metadata. + +Boundary: Synthetic client proves application ordering, not actual provider outage frequency; no public access or deployed retention incident shown. + +### F03: Documented ingestion recovery uses obsolete lock semantics + +R05 and worker runbook promise fencing during destructive recovery. + +Recovery refreshes locked_at/locked_by but not lease expiry/token. Current claims use lease_expires_at. Unfenced document reset/reset_document_index can follow a worker reclaim. Never-leased rows separately fail processing lease-tuple constraint. + +Acceptance: Two-session worker/recovery overlap cannot mutate after lease loss; Exhausted/fresh/legacy no-lease jobs have explicit safe outcomes; All artifact families/generations and job status remain coherent. + +Boundary: Fresh document-status predicate only protects its own update; exhausted final attempts cannot be reclaimed by this predicate. Stopping workers mitigates overlap but is not documented as mandatory. + +### F04: Roster hides failed rotation and course-booking sources + +R03 and Roster design require visible failed reads and retry, rather than an apparently empty calendar. + +Hook reports ready when no source loads, including errors; Roster consumes entries and local/team notices but does not surface calendar.sources failures. + +Acceptance: Fail rotations and bookings independently; Name each unavailable source and offer retry; Successful shifts remain visible; Recovery restores entries and clears warning. + +Boundary: No browser reproduction. Off/unavailable feature rollout and separate shift-fetch error handling are outside the claim. + +### F05: Diagnostics bypass minimization for filenames and fatal errors + +Sensitive diagnostic output should use bounded minimized identifiers and shared redaction. + +Upload storagePath contains original sanitized filename; logger retains synthetic name/DOB. Worker console redacts a path but fatal webhook interpolates raw error.message. + +Acceptance: Synthetic name/DOB path absent from diagnostic output; Synthetic path/URL/email/key errors absent from captured webhook JSON; Correlation and error class remain actionable. + +Boundary: Broader PHI/credential leakage from arbitrary errors is a risk hypothesis, not observed deployment evidence. + +### F06: Four recorded privacy conditions prevent clinical production release + +R04 production release requires verified evidence or scoped authorized decisions. + +Offline release validator fails ZDR, APP8 cross-border basis, privacy notice/contact and PHI minimisation status. Current register has 7 verified and 4 pending/partial requirements. + +Acceptance: Every covered requirement verified or genuinely accepted by authorized role; Scope restrictions clear to users/operators; Release check passes without altered gate rules. + +Boundary: No claim of legal noncompliance, deployment occurrence, or missing already-recorded OpenAI/Railway DPAs. + +### F07: Exposed dose and physiological calculators lack the dedicated source/review contract + +Documented clinical-governance rules require dedicated validation for dose advice and transparent source/formula attribution for scoring aids. + +Ordinary calculator UI exposes renal, QTc and lithium tools, including suggested doses and management advice. Its panel has no primary-source links, formula display or dated clinical-review record; the evidence provider and drift validator cover only eight psychometric fixtures. Eleven arithmetic probes pass but do not establish clinical validation. + +Acceptance: Displayed formulas, cutoffs and advice map to primary sources and a dated accountable review; Panel shows calculation and applicability limits; Changes invalidate the corresponding evidence contract. + +Boundary: No incorrect medical formula is alleged. Generic calculator-page disclaimer exists; this is a traceability/validation gap, not a claim the entire page lacks a disclaimer. + +### F08: Worker rebuild watch omits copied model-integrity inputs + +Committed automatic build selection must include material files copied into the worker artifact. + +Docker copies model-artifacts.json and verify-model-artifacts.mjs, but neither matches Railway worker watchPatterns. requirements.txt does match. Container CI includes all three, so its correct scope does not repair the deploy-trigger omission. + +Acceptance: A change to each of the three Docling build inputs independently selects a worker rebuild. + +Boundary: No live Railway configuration or missed deployment was observed. + +### F09: Opt-in cloud Python lock has incompatible Cython constraints + +The explicitly retained opt-in setup must resolve its locked dependency closure. + +Exact hash-locked Python3.12 installation fails: Cython3.3.0 is pinned while quicksectx0.4.1 requires Cython<3.2. The selected legacy bootstrap invokes this lock. + +Acceptance: Fresh selected cloud environment installs the unchanged regenerated lock with --require-hashes; pip check succeeds. + +Boundary: Default worker Docker uses Python3.11 and the canonical lock. Its failure was not established. Canonical installation on this Python3.12 host separately conflicts with medSpaCy/spaCy environment markers. + +### F10: Mounted Roster state and delayed deletion survive an account switch + +R03 requires owner-bound client state and auth-epoch checks before queued or asynchronous user effects. + +Three actual component/hook probes reproduce A settings remaining visible, A own shifts remaining visible, and A pending 30-second delete firing after a direct mounted transition to existing B with samples off. The queued request uses the current ambient session; the DELETE API correctly scopes to its current user. + +Acceptance: Direct mounted A-to-existing-B transition clears A settings and shifts; Pending A delete never dispatches under B; Normal same-account undo and delete remain correct. + +Boundary: Not every logout path is affected: sample gates may remount and cancel timers. Probe models ambient B cookies; no real B records were deleted and no backend IDOR was demonstrated. + +### F11: Previous pay-fortnight totals silently omit older roster days + +The displayed previous full fortnight and payslip prefill must include its entire date interval. + +AdminPayPage requests shifts without a range; the API defaults to the last21days. On17Oct2026, the previous fortnight is21Sep–4Oct while the API starts26Sep. A real-component probe with two8h shifts on22/28Sep prefills8 rather than16hours. + +Acceptance: All days in this and previous pay fortnight are fetched; Boundary fixture prefills16hours; Custom fortnight anchors and empty periods remain correct. + +Boundary: No payroll transfer or actual wage loss observed; this is an advisory hours/payslip-prefill defect. + +### F12: Fixed Perth offsets corrupt supported selected-zone work calculations + +src/lib/work-time/zones.ts promises work dates and times use the selected supported work timezone, including Sydney. + +Three pure actual-module probes in Sydney reproduce: midnight–08:00 shift counted as5h instead of8h; a shift after the seven-local-day window is included; changing only a Teaching room moves a noon session to15:00. Helpers construct dates with fixed+08:00 after formatting selected-zone clock values. + +Acceptance: Sydney shift reports8h; Outside-window shift is excluded; Room-only edit preserves exact session instant; Perth and DST-boundary cases remain correct. + +Boundary: Perth default is not disproved; broader other inline offsets were inspected statically, not all reproduced. + +### F13: Source governance changes can leave stale clinical answers cache-valid + +A source made outdated or decommissioned must cease contributing as current reviewed evidence to new answers. + +Review RPCs update governance and updated_at but preserve indexed_at, which dominates the cache version. Two actual-module probes reproduce stale local reuse and, on one process after local invalidation, stale shared_answer_cache_hit when shared deletion returns a PostgREST error. Final policy receives cloned old metadata; supplying current outdated metadata correctly refuses. + +Acceptance: After current-to-outdated/decommissioned review, matching local/shared answer and search cache entries cannot be reused; Returned DB deletion errors and concurrent reads fail safely; Unchanged content and valid ownership still cache normally. + +Boundary: Supported default legacy document-scoped/non-original-section cache path only. Candidate context snapshots, anonymous requests, skipCache, active pilot/reviewed loaders and other bypasses limit exposure. No hosted stale clinical answer or patient harm observed. + +### F14: Concurrent Teaching reassignment can retain an old registrar log under a new pairing + +Supervision entries must remain attached to the authorized registrar/supervisor relationship under concurrent writes. + +pairing.save locks and reassigns an empty pairing after checking for no logs. supervision.log reads/authorizes the old pairing without a conflicting parent/advisory lock, then inserts. Its FK binds pairing_id and service_id, not registrar identity. A blocked insert can resume after reassignment without rechecking and then be read through the new pairing. + +Acceptance: Two-session old-registrar log versus empty-pairing reassignment cannot commit inconsistent attribution; Normal duplicate/retry and authorized logs remain correct. + +Boundary: Source independently challenged; no production incident or full current Supabase replay observed. + +### F15: Hospital sick-report endpoint bypasses the real-staff release hold + +Privacy impact assessment holds real-staff Roster/Admin/Teaching personal-data use pending the documented approval/isolation/residency conditions. + +Actual route with production mode, real-staff release flagheld, authenticated authorized workforce role and synthetic existing rows returns200 with named sick reports and calls the reader. An enabled-release positive control returns200. Neighboring release-gated paths do not automatically gate this route. + +Acceptance: Held production release returns the documented unavailable status without reading real staff data; Explicitly enabled release still requires current role and hospital scope; Demo data remains clearly separated. + +Boundary: Requires authenticated authorized workforce/site role and existing real rows. This is not anonymous access or evidence that rows are currently deployed. The release-scope conclusion assumes this roster-derived hospital projection is covered by the broad PIA real-staff hold. An authoritative separate hospital-data approval would disconfirm that scope; none was found. The probe mocks role/auth and repository rows, so it proves the missing branch and reader call, not actual RLS/role enforcement. + +### F16: SLO runbook understates implemented alert signals + +Operator guidance should accurately describe configured diagnostic signals. + +Runbook states only two rates/alerts are computed, while source implements additional source-gap, unsupported-answer and latency-budget breach warnings. + +Acceptance: Every current alert code maps to its actual definition and operator action; No claim of verified delivery without an observed test. + +Boundary: Low-impact documentation drift. Actual hosted detection/delivery remains Unverified in J1. + +## Additional retained leads + +- HY01: Additional stale favourites responses may alter set metadata or expire a new session. Same missing epoch pattern; only membership-save success transition reproduced. Verification: Delayed GET body, structured mutations and stale 401 under immutable per-render identity fixtures. +- HY02: Configured worker fatal errors could carry sensitive provider details. Raw egress proven; actual sensitive deployment payload unknown. Verification: Intercept synthetic provider-shaped exception before any real webhook delivery. +- HY03: Factsheet short-summary use policy loses demonstration/date context. Eight eligible short clinical-reference summaries omit the demonstration-use notice and call content month Review date. Local source checking can support locally_reviewed; no authoritative short-summary prohibition established. Patient handout prohibition alone does not prove this projection forbidden. Verification: Clinical/content owner defines summary-use policy, then adapter/manifest/release regression preserves required notice and date semantics. +- HY04: Additional Admin/leave/share state may lack identity fencing. Source patterns in New job PATCH, sharing and basic leave hooks warrant follow-up; no actual stale response counterexample or effective remount closure established. Verification: Actual mounted immutableA/B auth fixtures for each live consumer; trace store reset/remount before promoting finding. +- HY05: People team-move and whole-team-supervisor revocation are not atomic. moveTeam commits hospital link before supervisor revocation; second-write failure explicitly reports503 and manual recovery; retry sees same destination409. Models can retain old whole-team authority, but current production assessment routes refuse503 and no live assessment consumer found. No present assessment disclosure alleged. Verification: Before activating assessment review, atomically move/revoke or derive scope from current hospital; failure-injection must remove old-hospital authority. Keep explicit recovery path. +- HY06: Teaching confirmation/reassignment admitted-operation semantics. Confirmation does not acquire the same pairing lock. Whether an admitted old-supervisor operation is forbidden after reassignment needs defined semantics and actual SQL ordering; not established as separate defect. Verification: Define linearization requirement; current-RPC two-session confirmation/reassignment test. +- HY07: Forwarded host/IP and Edge gateway trust assumptions. Source relies on hosting proxy sanitization and configured Edge JWT verification. No deployed header or gateway override tested. Verification: Separately authorized staging edge-origin/cookie/header and gateway configuration verification. + +AD01: Moderate registry advisory GHSA-hp3w-g68c-fv3c through Mammoth CLI dependencies; library reachability not established. Seek a compatible fix; never blindly downgrade Mammoth. + +## Authority and completion + +Implement and test local code/configuration fixes and draft migrations. Do not change live databases, merge, push, deploy, send messages, call application providers or pay for canaries. Do not invent clinical sign-off, legal decisions, ZDR status or source approval. Keep unmet external conditions explicit and fail closed. A local code fix, tested migration, owner acceptance and deployed verification are distinct. diff --git a/docs/database-drift-detection.md b/docs/database-drift-detection.md index 15d148e784..21feb5680d 100644 --- a/docs/database-drift-detection.md +++ b/docs/database-drift-detection.md @@ -430,6 +430,46 @@ before the list existed. - After the pending-migration backlog lands, delete the matching allowlist entries; check:drift reports stale entries so they cannot silently linger. +### Optional offline rootless replay + +Docker remains the default backend and CI continues to use the cached pinned image. On a +non-root Linux amd64 host with usable bwrap user namespaces, an explicit rootless route can +consume a separately acquired local OCI image and trusted umoci executable: + +```bash +npm run drift:manifest -- --backend rootless --oci-layout /absolute/private/oci --scratch-root /absolute/private/scratch --umoci /absolute/private/tools/umoci --umoci-sha256 --evidence /absolute/private/evidence/replay.json +``` + +The scratch and evidence parent directories must already exist, belong to the calling user and +have private permissions (0700); the evidence filename must be new. OCI and scratch directories +must be separate. The generator never downloads images/tools, installs packages or falls back +between backends. Obtain the executable from verified package/publisher provenance separately; +a caller-supplied hash checks matching bytes and does not itself establish publisher authenticity. +Rootless rejects Docker-specific `--keep`, `--port` and `--container` options. + +This route verifies the original pinned index, selected linux/amd64 manifest, config, compressed +layers and ordered uncompressed diff IDs, then freshly unpacks through the hash-checked OCI-aware +extractor. It checks the actual image's postgres UID/GID (100/101) and supported native startup +settings. The original image root stays read-only; only private data/socket/tmp and copied +PostgreSQL/config-key directories are writable. It clears inherited host environment, isolates +network/PID/user/mount namespaces and publishes no TCP port. The ephemeral password travels on +stdin; native private-key material and raw logs are not exported to evidence. + +One guest supervisor runs the original entrypoint, waits for its initialization-complete marker +and five consecutive readiness successes, then serves the native generator's shared role +bootstrap, dynamically owned Storage scaffold, complete postgres schema replay and actual catalog +capture. The same PID namespace also performs shutdown. A forced stop or nonzero pg_ctl/server +exit fails acceptance. Only after successful replay and confirmed graceful cleanup does the +native generator atomically replace the manifest; SQL/capture/cleanup failure preserves the +previous manifest. Cleanup removes only its newly created owned run directory. + +The committed manifest retains the original requested image reference, the same native generator +identity and existing fields; runtime/provenance details and the produced manifest hash live in +the separate private evidence JSON. Freshness still deep-compares everything except generated_at +and replay_seconds. This local schema-mirror replay does not establish migration-chain replay, +hosted deployment, real Storage behavior or live acceptance. Retain those distinct gates and +production approval boundaries. + ## 2026-07-07 baseline audit (three-way: live vs schema.sql vs migration chain) Both repo lineages were replayed into scratch containers and compared with the diff --git a/docs/error-tracking.md b/docs/error-tracking.md index 34f778acc1..43b353d9e5 100644 --- a/docs/error-tracking.md +++ b/docs/error-tracking.md @@ -31,7 +31,7 @@ Privacy constraints for agent monitoring: - `recordInputs` / `recordOutputs` are **false** on the wrap and `dataCollection.genAI` is `{ inputs: false, outputs: false }` in both runtime configs — prompts, clinical queries, source evidence, generated answers, and embedding inputs are never recorded. - `privacySafeTransactionEvent` allowlists only gen_ai metadata attributes (system, operation name, request/response model, response id, finish reasons, token usage, conversation id) and rebuilds gen_ai span descriptions as ` ` from those attributes. Message, prompt, tool-payload, and embedding-input attributes are stripped on export even if a future SDK version records them. -- Each answer / stream / document-summarize request calls `Sentry.setConversationId()` **before** OpenAI work — the request's synthetic UUID — so the embedding/generation calls of one request group into one conversation without carrying any query text. +- Each answer / stream / document-summarize request calls `Sentry.getIsolationScope().setConversationId()` **before** OpenAI work — the request's synthetic UUID — so the embedding/generation calls of one request group into one conversation without carrying any query text. The public scope method exists in both the installed Node and Edge entries; UUID syntax is checked, while callers remain responsible for supplying the generated request ID. - User identification (`Sentry.setUser`) is deliberately **not** wired: the committed privacy boundary strips `user` from every outgoing event (see the tests), and linking clinical-query telemetry to an identity would need its own governance review first. - `responses.parse` (schema-parsed generation) is not in the SDK's instrumentation registry and emits no gen_ai span; `responses.create` and `embeddings.create` are covered. @@ -110,3 +110,9 @@ When enabling logs, inspect a sample in **Explore → Logs** and confirm bodies ## Disable and rollback Remove `SENTRY_DSN` and restart the service. The tracker then makes no provider calls. To disable only performance tracing while keeping error capture, set `SENTRY_TRACES_SAMPLE_RATE=0` and restart. To disable only structured logs while keeping errors/traces, set `SENTRY_ENABLE_LOGS=false` and restart. Provider-side deletion and retention remain operator responsibilities under the approved Sentry project policy. + +## Content-free upload and fatal diagnostics + +Upload cleanup logs contain an opaque SHA-256 object reference and fixed operational error classes/codes, rather than storage paths, filenames, owner IDs or provider prose. The digest is an aid for comparing a separately authorised scoped storage inventory; it is not a replacement for durable cleanup records. Exact paths remain in the owner-scoped cleanup queue when that write succeeds. Unknown error names/codes are withheld. The logger also redacts filename and storage/document-path context keys as defense in depth. + +Fatal worker console and notification payloads omit raw exception text. Notifications retain the fixed failure state; the existing privacy-filtered error-tracking path retains its stage and scrubbed stack information. No notification was sent during local tests. These measures minimise known diagnostic egress; they do not constitute clinical PHI-minimisation approval or a complete deidentification guarantee. diff --git a/docs/governance/clinical-release-remediation-handoff-2026-10-17.md b/docs/governance/clinical-release-remediation-handoff-2026-10-17.md new file mode 100644 index 0000000000..95e4212169 --- /dev/null +++ b/docs/governance/clinical-release-remediation-handoff-2026-10-17.md @@ -0,0 +1,66 @@ +# Clinical release boundaries: remediation handoff + +This is the scoped engineering handoff for F06, F15 and HY03. It is not an approval record or a +new task ledger. The [privacy-readiness register](privacy-readiness.v1.json) remains the status +authority; the [production-readiness gate](../production-readiness.md#fail-closed-invariant) and +its validator are unchanged. + +## F06: external evidence remains outstanding + +The register retains seven verified requirements and these four release blockers: + +| Requirement | Status and accountable role | Genuine evidence or authorized decision still required | +| ------------------------------------ | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PRIV-PROVIDER-OPENAI-ZDR` | `pending`; OpenAI account owner | Production API-key organisation/project match without publishing secrets; current provider approval/configuration covering deployed Responses and Embeddings models; effective prompt-cache retention/TTL; sharing controls; dated sanitized evidence and expiry. Request acknowledgment, `store:false` and disabled logging do not establish ZDR. | +| `PRIV-LEGAL-APP8-CROSS-BORDER-BASIS` | `pending`; Privacy adviser | Whole-flow determination covering Railway Singapore, OpenAI overseas processing, subprocessors, retention, security, deletion and incident terms; applicable basis, restrictions, residual risk and whether real-patient use is approved, restricted or prohibited. | +| `PRIV-LEGAL-APP1-APP5-NOTICE` | `pending`; Privacy adviser | Accountable legal entity/jurisdiction, privacy contact, complaints/escalation and access/correction/deletion process, collection purpose and authority/consent basis, recipients/countries, change maintenance, intended audience and approved version/effective/review dates. These details cannot be invented by engineering. | +| `PRIV-CLINICAL-PHI-MINIMISATION` | `partial`; Clinical safety owner | Named intended deployment and explicit residual-risk decision: accept a trained non-identifying workflow with monitoring/escalation, require evaluated fail-closed egress controls with clinical-preservation criteria, or prohibit provider-backed real-patient use. Warnings and identifier-shaped checks do not guarantee de-identification. | + +For every closed requirement record the requirement ID, actual `verified` or `accepted_decision` +decision, authorized role, rationale, intended scope, decision/review-expiry dates and sanitized +external evidence reference. An accepted restricted-use decision does not assert that the underlying +provider entitlement was verified. Keep confidential contracts, screenshots, personal signatures, +secret values and patient information outside Git. The existing +[role-attestation pack](privacy-role-attestation-pack-2026-09-01.md) contains the decision prompts; +use the current register and October agreement records where older prose differs. + +The OpenAI and Railway agreement requirements are already recorded as verified on 2026-10-05 for +public references and wholly invented examples, excluding real-patient cases and voice. Their +recorded expiry is 2026-12-01. This handoff neither reopens those agreements nor extends their scope +or expiry. See the [OpenAI](openai-standard-agreement-evidence-2026-10-05.md) and +[Railway](railway-dpa-completion-evidence-2026-10-05.md) evidence records. + +Clinical production activation and patient-data processing remain blocked under the existing gate. +A restricted non-identifying deployment still needs an actual authorized assessment, including +account-data risk. Ordinary local development is not approval. After genuine evidence is supplied, +the authorized owner updates the existing register and runs the existing release check. No gate, +status, legal determination, provider setting or live database was changed here. + +## F15: hospital sick reports share the real-staff hold + +The endpoint projects named roster inability-to-attend/cover reports; it does not read an illness +reason or diagnosis. In production it now returns 503 before creating the real admin client or +reading roles/reports unless `ROSTER_TEAM_RELEASE_ENABLED` is exactly `true`. Demo mode continues to +refuse the real reader. Explicit enablement preserves authentication, rate limiting, fresh role +loading and the reader's hospital-scoped capability check. + +This applies the broad Roster/Admin/Teaching real-staff hold in the +[PIA](../privacy-impact-assessment.md#roster-release-2-local-implementation-assessment-2026-09-27) +to a roster-derived hospital projection. A separately authorized hospital-dashboard policy could +change that scope; no such approval was supplied or invented. Service privacy approval, isolation +proof and the Singapore application-server/residency determination remain external requirements. +Synthetic route/helper tests do not prove live auth, RLS, existing staff rows or deployed enablement. + +## HY03: Factsheet summary context + +The short clinical-reference projection now retains the existing demonstration/not-clinician-approved +notice and labels `reviewedOn` as `Content month`. The shared notice and date meaning are included in +source lineage, so that context participates in publication identity as well as the body hash. + +`locally_reviewed` continues to mean source correspondence under +[Source Acquisition: Sign-off](../source-acquisition-protocol.md#4-sign-off), distinct from formal +clinical approval. Currentness and manifest eligibility are unchanged. This correction preserves +context; it does not invent a new rule prohibiting the short-summary corpus, certify the full +handout or claim that the existing local review was false. The clinical/content owner still needs +to resolve the short-summary corpus's intended audience and permitted use explicitly. No corpus +sync, index publication, hosted activation or provider-backed retrieval comparison occurred here. diff --git a/docs/governance/physiological-calculator-hold-2026-10-17.md b/docs/governance/physiological-calculator-hold-2026-10-17.md new file mode 100644 index 0000000000..6769860096 --- /dev/null +++ b/docs/governance/physiological-calculator-hold-2026-10-17.md @@ -0,0 +1,33 @@ +# Physiological calculator containment and review handoff + +The renal, QTc and lithium bedside panel is withheld pending dedicated clinical validation. +This is containment of audit finding F07, not clinical validation or a finding that a formula is +medically incorrect. The ordinary psychometric catalogue keeps its separate evidence, rights and +quarantine rules. Its page disclaimer remains in place. + +The panel shows an unavailability notice on desktop and phone, including when an open panel or a +particular tab was requested. It exposes no physiological inputs, numerical results, classifications, +dose suggestions or management advice. The original calculation helpers and draft display code are +retained. Engineering arithmetic tests continue to exercise those helpers; passing them cannot +approve a clinical dose tool. + +The inspectable [implementation inventory](../../src/lib/calculators/clinical-math/governance.json) +records the formulas implemented, named source leads, unresolved applicability/threshold/advice +questions, and the missing clinical reviewer, review date and approval artifact. These are +implementation attributions, not certification of primary-source correspondence. Exact hashes bind +the math, types, export surface and panel to that inventory. The maintained +`tests/clinical-math-governance.test.ts` fails on drift or unlisted exported calculations; updating a +hash only records a code change and does not validate it. + +Before restoring any part of the panel, the accountable clinical owner must supply a dated review +artifact covering the exact implementation and intended population, with source versions and +claim-level correspondence for formulas, units, rounding, cutoffs, exclusions and advice. Lithium +dose adjustment specifically needs the dedicated dose-calculator validation required by +[Clinical Use Rules](../clinical-governance.md#clinical-use-rules). Clinical ownership and any +deployment classification/TGA assessment remain role-bound. + +A reviewed restoration must show the permitted primary-source links, calculation and reference +limitations at the tool itself, preserve invalid-input and safety boundaries, and re-establish +focused engineering and UI evidence. Merely changing release metadata is not sufficient evidence. +No clinical reviewer, approval date, remote source check, provider entitlement or deployment is +created by this containment change. diff --git a/docs/observability-slos.md b/docs/observability-slos.md index 782ee88118..f32eaeba97 100644 --- a/docs/observability-slos.md +++ b/docs/observability-slos.md @@ -26,31 +26,34 @@ text. ## 2. SLOs and alert thresholds -Measurement window is trailing 24 h unless stated; "page" means the loudest -channel available (today: GitHub issue from the canary + host alert; later: -host-native alerting per `docs/deployment-architecture.md`). - -**Automation status — read this before trusting a threshold.** Only two of the -objectives below are actually computed and alerted by this repository: the -hybrid-RPC error rate and the degraded/source-only answer rate, both from -`answerSloSnapshot` (`src/lib/observability/answer-slo.ts`), surfaced on the -`/api/health?deep=1` deep probe and raised by the ops digest as -`OPS_HYBRID_RPC_ERROR_RATE_*` and `OPS_DEGRADED_ANSWER_RATE_*` -(`scripts/lib/operational-alerts.mjs`, whose complete code list is -`OPS_ANSWER_SLO_UNKNOWN`, `OPS_HYBRID_RPC_ERROR_RATE_{UNKNOWN,WARNING,PAGE}`, -`OPS_DEGRADED_ANSWER_RATE_{UNKNOWN,WARNING,PAGE}`, -`OPS_PROJECTED_DAILY_SPEND_WARNING` and `OPS_EVAL_CANARY_STALE_WARNING`). - -No **production monitor** computes an answer-latency percentile, a source-gap -rate, or an unsupported rate, and no alert code exists for any of them. (The -eval harness does compute a latency percentile - `scripts/eval-retrieval.ts` -enforces a p90 budget over its evaluated cases, cited under "Anchors" below - -but it runs on demand against a fixture corpus, so it says nothing about live -traffic.) Their "warn" and "page" rows below are **targets with a hand-run SQL -probe, not alerts**: a breach is invisible until an operator runs the query. -Each such section is labelled `Manual SQL - not alerted`. Do not cite an unlabelled -threshold as evidence of monitoring coverage, and if one of these is wired up -later, add its `OPS_*` code here and remove the label in the same change. +Manual target windows are stated below. Configured ops-digest answer alerts use +exactly one trailing 60-minute snapshot from `answerSloSnapshot` +(`src/lib/observability/answer-slo.ts`) through the secret-gated +`/api/health?deep=1` probe. The checked-in digest runs daily, so this does not +provide continuous hourly coverage. "Page" is a policy severity; configuration +does not establish that any provider notification was delivered or acknowledged. + +**Automation status — read this before trusting a threshold.** +`scripts/lib/operational-alerts.mjs` configures these answer signals: + +| Signal | Configured code(s) | Trigger in a valid 60-minute snapshot | +| --------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| Missing/unassessed SLO telemetry | `OPS_ANSWER_SLO_UNKNOWN` | Missing SLO block, nonpositive/invalid query count, or window other than 60 minutes | +| Hybrid-RPC error rate | `OPS_HYBRID_RPC_ERROR_RATE_{UNKNOWN,WARNING,PAGE}` | Unknown for an invalid rate; warn above 0.5%; page only for the same RPC in three persisted contiguous hourly windows | +| Provider-generation degradation | `OPS_DEGRADED_ANSWER_RATE_{UNKNOWN,WARNING,PAGE}` | Unknown for an invalid rate; warn above 20%; page above 50% | +| Source-gap rate | `OPS_SOURCE_GAP_RATE_WARNING` | `sourceGapRate > 0.15`, with at least 20 answered queries | +| Unsupported-answer rate | `OPS_UNSUPPORTED_ANSWER_RATE_WARNING` | `unsupportedRate > 0.15`, with at least 20 answered queries | +| Answer latency-budget breach rate | `OPS_ANSWER_LATENCY_BUDGET_WARNING` | `latencyBudgetBreachRate > 0.15`, with at least 20 answered queries | + +The complete code list also includes `OPS_PROJECTED_DAILY_SPEND_WARNING` and +`OPS_EVAL_CANARY_STALE_WARNING`; every code has an operator runbook below. +Source-gap, unsupported and latency-budget signals have no dedicated unknown or +page code: missing/invalid rates or fewer than 20 answers suppress those warnings +and do not prove health. The latency signal counts budget breaches, **not p95**. +Production latency percentiles and the longer-window quality targets below +remain manual probes. The eval harness's p90 budget covers its fixture cases, +not live traffic. Configured detection and observed delivery remain separate; +the acknowledgement/recovery drill below is still externally unaccepted. ### Latency — answer p95 by route mode @@ -92,10 +95,31 @@ provider confirmation boundary. `LIVE_DOMAIN_URL` and `ANSWER_PROBE_QUERY` overr Read its output with the nesting in mind: `search` already contains `rpc`, `embedding` and `rerank`, and `answer`/`total` are whole-request totals, so the column does not sum. -**This does not make the section above alerted.** It is an operator probe run by hand, not a -monitor: nothing schedules it and no workflow calls it, so the `Manual SQL - not alerted` label -stands and no `OPS_*` code exists for answer latency. `#TN512M` is the reason it was deliberately -left unscheduled — detection here already outruns attention. +The timing probe is run by hand and does not compute a production percentile +or schedule monitoring. The `Manual SQL - not alerted` label applies to the +p95 targets above. The separate configured latency-budget warning counts rows +over their route budget; it does not establish a p95 breach. `#TN512M` records +why the timing probe was left unscheduled. + +### Latency — configured answer-budget breach rate + +`OPS_ANSWER_LATENCY_BUDGET_WARNING` warns above 15% in a 60-minute window +containing at least 20 answered queries. `latencyBudgetBreachRate` counts an +explicit `latency_budget_breached: true` or a finite latency over the row's +route budget: fast/default 10,000 ms, strong 25,000 ms, extractive/source-only +6,000 ms. It converts the first non-null latency field in this order: +`latency_ms`, `duration_ms`, `total_latency_ms`, `response_ms`; an invalid +earlier value does not fall through to a later field. Route selection uses +`routing_mode`, `mode`, `route_mode`, then `answer_mode`, normalised to lower +case. `extractive`, `source_only` and `source-only` share the 6-second budget; +`strong` uses 25 seconds and all other values default to 10 seconds. +There is no `/api/search` percentile or page signal in this rate. + +Latency uses at most 1,000 answer metadata rows. When that sample is full and +the window contains more answers, its denominator is the sampled row count; +otherwise it is the whole-window answer count. Missing/invalid timing does +not prove an answer met its budget. Interpret this bounded diagnostic rate +with sample coverage and route mix; never label it an exact population p95. ### Database latency — Sentry production DB span SLO (#183) @@ -110,30 +134,40 @@ Sentry metric alert criteria for production database query span duration. Guards ### Quality — source-gap rate -_Manual SQL - not alerted. No code computes this rate._ +_Configured warning: `OPS_SOURCE_GAP_RATE_WARNING` above 15% over 60 minutes, +with at least 20 answered queries. The longer-window targets below are manual._ -Share of answered queries whose confidence collapsed to a gap -(`fallback_reason` or support modules report `source_gap`). +`sourceGapRate` counts answered-query rows matching `source_gap`/`sourceGap` +true; fallback reason codes `source_gap`, `coverage_gap`, `no_documents`, +`no_chunks`, `insufficient_evidence`; answer outcomes `source_gap`/`coverage_gap`; +or fallback reason text containing `source_gap`/`coverage_gap`. Counts and the +denominator cover the whole answer window, excluding search `event_type` rows. - **SLO:** ≤ 15 % of answers over 7 days. -- **Warn:** > 20 % over 24 h. **Page:** > 30 % over 6 h (step change — +- **Manual warning target:** > 20 % over 24 h. **Manual page target:** > 30 % over 6 h (step change — suggests retrieval, enrichment, or corpus regression, not user behavior). ### Quality — unsupported rate -_Manual SQL - not alerted. No code computes this rate._ +_Configured warning: `OPS_UNSUPPORTED_ANSWER_RATE_WARNING` above 15% over +60 minutes, with at least 20 answered queries. Longer-window targets are manual._ -Share of queries with `routing_mode = 'unsupported'` or -`confidence = 'unsupported'`. A base rate is legitimate (out-of-corpus -questions), so alert on deviation, not existence. +`unsupportedRate` counts `unsupported: true`, `answer_supported: false`, +`confidence`/`confidence_level`/`grounding_status: unsupported`, or +`fallback_reason_code`/`answer_outcome: unsupported`. It uses the whole-window +answer count, excluding search `event_type` rows. `routing_mode` alone does not +select the configured counter. A base rate is legitimate for out-of-corpus +questions; investigate the changed corpus/query mix before declaring regression. - **SLO:** ≤ 10 % of queries over 7 days. -- **Warn:** > 15 % over 24 h. **Page:** > 25 % over 6 h, or a doubling versus +- **Manual warning target:** > 15 % over 24 h. **Manual page target:** > 25 % over 6 h, or a doubling versus the trailing-7-day rate. Known confounder: the nondeterministic unsupported short-circuit (finding #11) — check its memoization before declaring a regression. ```sql +-- Manual routing/confidence diagnostic; narrower/different than the configured +-- unsupportedRate filters listed above. It is not the ops-digest counter. select count(*) filter (where metadata->>'routing_mode' = 'unsupported' or metadata->>'confidence' = 'unsupported')::float @@ -146,15 +180,16 @@ where created_at > now() - interval '24 hours'; _Alerted: `OPS_HYBRID_RPC_ERROR_RATE_{UNKNOWN,WARNING,PAGE}` via `answerSloSnapshot`._ -Share of queries whose metadata contains a non-empty `hybrid_rpc_errors` map. +Share of answered-query rows with non-null `hybrid_rpc_errors` metadata. This is the direct guard against the historical silent-RPC-death incident, so tolerance is near zero. - **SLO:** 0 sustained errors. Isolated blips (< 0.1 % over 24 h) tolerated. - **Warn:** > 0.5 % of queries in any 1 h window. -- **Page:** any _sustained_ nonzero rate across 3 consecutive hours, or the - same RPC name failing repeatedly — the app will look healthy while serving - fallback-quality answers, which is exactly the failure mode to catch. +- **Page:** a positive current rate plus complete persisted evidence of the + same safe RPC identifier failing in each of three contiguous UTC-hour + buckets. A generic sustained rate or repeated names in one snapshot is + insufficient. The daily schedule alone cannot supply that history. ```sql select @@ -177,13 +212,17 @@ _Alerted: `OPS_DEGRADED_ANSWER_RATE_{UNKNOWN,WARNING,PAGE}` via `answerSloSnapsh answers when generation fails quality gates. Expected occasionally; a spike means the OpenAI path is broken while users still get 200s. -- **SLO:** ≤ 10 % of grounded answers over 24 h. +- **Manual SLO target:** ≤ 10 % of grounded answers over 24 h. The configured + hourly `degradedRate` denominator is all answered queries, excluding search + `event_type` rows. - **Warn:** > 20 % over 1 h. **Page:** > 50 % over 1 h (generation is effectively down). Measure provider-generation degradation via the explicit -`metadata->>'provider_generation_degraded' = 'true'` flag. During rollout, the -probe also counts legacy `fallback_reason ILIKE '%generation_fallback:%'` rows so +`metadata->>'provider_generation_degraded' = 'true'` flag or fallback reason +codes `provider_auth`, `provider_quota`, `provider_rate_limit`, +`provider_timeout`, `provider_failure`. During rollout, the probe also counts +legacy `fallback_reason ILIKE '%generation_fallback:%'` rows so recent pre-flag provider failures remain visible until they age out. Keep `degraded` for the broader source-only UI state and `fallback_reason` as diagnostic detail; neither is narrow enough for provider health on its own. @@ -466,6 +505,27 @@ The rolling issue is the provider-neutral queue. For every alert, the on-duty op - **Recover:** require a subsequent valid hourly snapshot at or below `20%` plus confirmation that fallback messaging remains intact. - **Escalate:** Clinical safety owner immediately; involve the product owner if service restriction or rollback is considered. +### `OPS_SOURCE_GAP_RATE_WARNING` + +- **Diagnose:** verify a valid 60-minute snapshot with at least 20 answers and `sourceGapRate > 0.15`. Inspect content-free fallback-code counts, corpus coverage and recent retrieval/enrichment changes; this whole-window rate is distinct from the manual 24-hour target. +- **Acknowledge:** Clinical evaluation owner records the rate, answer count and window, and whether the query mix changed. +- **Recover:** require a subsequent valid 60-minute snapshot with at least 20 answers and `sourceGapRate <= 0.15`; a missing rate or low-volume window is unassessed, not recovery. +- **Escalate:** Clinical safety owner if high-risk clinical coverage is affected or the warning persists into a second measured window. Ranking/corpus changes retain their normal approval and canary safeguards. + +### `OPS_UNSUPPORTED_ANSWER_RATE_WARNING` + +- **Diagnose:** verify at least 20 answered queries in a valid 60-minute snapshot and `unsupportedRate > 0.15`. Compare the documented unsupported metadata filters and corpus/query mix; unsupported questions can be legitimate, and this is not the manual routing-mode SQL counter. +- **Acknowledge:** AI platform owner records the rate, answer count, window and whether unsupported outcomes are expected for the current corpus. +- **Recover:** require a subsequent valid 60-minute snapshot with at least 20 answers and `unsupportedRate <= 0.15`; missing/invalid rates or fewer answers do not establish recovery. +- **Escalate:** Clinical safety owner if previously supported clinical questions lose support or the warning persists into a second measured window. + +### `OPS_ANSWER_LATENCY_BUDGET_WARNING` + +- **Diagnose:** verify at least 20 answers in a valid 60-minute snapshot and `latencyBudgetBreachRate > 0.15`. Inspect the 1,000-row sampling limit, timing-field coverage and route budgets (fast/default 10 s, strong 25 s, extractive/source-only 6 s). Separate sampled breach counts from production p95 and use an authorised timing probe to identify the slow stage when needed. +- **Acknowledge:** Platform operations records the breach rate, whole-window answer count, sampled-row coverage and affected route mix; never label the warning a p95 incident without percentile evidence. +- **Recover:** require a subsequent valid 60-minute snapshot with at least 20 answers and `latencyBudgetBreachRate <= 0.15`, plus confirmation that timing coverage remains measurable. A sparse/invalid snapshot is not recovery. +- **Escalate:** AI platform owner if the warning persists into a second measured window or provider timeouts affect users; provider or model configuration changes remain approval-gated. + ### `OPS_PROJECTED_DAILY_SPEND_WARNING` - **Diagnose:** compare the projection threshold, sample-truncation flag, answer volume, and route-level spend; treat a truncated sample as a lower bound. diff --git a/docs/organisation/systems/data-platform.json b/docs/organisation/systems/data-platform.json index 6e2d0ce721..99a736492e 100644 --- a/docs/organisation/systems/data-platform.json +++ b/docs/organisation/systems/data-platform.json @@ -52,6 +52,7 @@ "scripts/check-supabase-project.ts", "scripts/generate-drift-manifest.ts", "scripts/lib/cross-tenant-*", + "scripts/lib/drift-rootless.ts", "scripts/lib/find-owner-id-by-email.ts", "scripts/lib/operational-alerts.mjs", "scripts/lib/reviewed-commit-history-decision.mjs", @@ -73,6 +74,7 @@ "src/app/api/webhooks/**", "src/app/auth/callback/**", "src/components/clinical-dashboard/password-recovery-form.tsx", + "src/components/use-account-request-scope.ts", "src/components/work-mode-launch/**", "src/instrumentation-client.ts", "src/instrumentation.ts", @@ -137,6 +139,7 @@ "tests/clinical-quality-triage-schema.test.ts", "tests/credential-revocation-and-rotation.test.ts", "tests/cross-tenant-*", + "tests/diagnostic-minimisation.test.ts", "tests/document-admin-rate-limit.test.ts", "tests/document-corpus-access-mode.test.ts", "tests/documents-is-public.test.ts", diff --git a/docs/organisation/systems/personal-practice.json b/docs/organisation/systems/personal-practice.json index f2238d8732..21e6ec3ca0 100644 --- a/docs/organisation/systems/personal-practice.json +++ b/docs/organisation/systems/personal-practice.json @@ -113,6 +113,7 @@ "tests/work-profile*", "tests/work-roles-hook.dom.test.tsx", "tests/work-roles-hospital-hub.test.ts", + "tests/work-roles-hospital-sick-*.test.ts", "tests/work-roles-hospital-sick.test.ts", "tests/work-roles-hospital-starters-model.test.ts", "tests/work-roles-hospital-starters.test.ts", diff --git a/docs/rag-behaviour/README.md b/docs/rag-behaviour/README.md index 48c9442c0d..745bc4f119 100644 --- a/docs/rag-behaviour/README.md +++ b/docs/rag-behaviour/README.md @@ -12,6 +12,7 @@ selection, release-ordering, or eval-ground-truth surface. | `behaviour-map.md` | The verified mechanics: score imputation sites, the release comparator chains, the gate/threshold ladder, second-stage engagement rules, and which live cases exercise which path. | | `refuted-approaches.md` | Live-refuted improvement attempts (feature-weight tuning, saturation-tail spread, governance ranking penalties/boosts) with numbers, post-mortems, and binding constraints. | | `safeguards.md` | The protection stack: protected-surface list, the advisory pr-policy `RAG impact:` warning, the source-pin contract test, the canary-pair protocol, and the regeneration procedures. | +| `cache-freshness.md` | Document-governance cache revisions, read/write fences, safe application/database compatibility and local proof limits. | Standing rules (mirrored in `AGENTS.md` so every agent session inherits them): diff --git a/docs/rag-behaviour/cache-freshness.md b/docs/rag-behaviour/cache-freshness.md new file mode 100644 index 0000000000..3cffabe1f5 --- /dev/null +++ b/docs/rag-behaviour/cache-freshness.md @@ -0,0 +1,82 @@ +# Document governance and cached evidence + +The F13 remediation replaces the latest-indexed-document stamp with the authoritative +`rag_cache_scope_revision_v1` database read. The complete document population in the +request's owner/public and optional document scope determines the revision. Every row's +id, owner, status, updated time, metadata and index generation participate. Each row is +hashed before aggregation; the response contains only a versioned 51-character digest. +The SQL and service-role-only grant are maintained in the database schema/migration. + +Local answer/search hits require a fresh revision. A revision retained at request start +cannot skip that read. Shared answer/search hits also re-read the revision after fetching +the cache payload, so a review committed during the shared read causes a miss. A late +write with the old retrieval revision remains unreachable after a source change, even if +the shared cache deletion failed. Write promotion compares the current revision with +the retrieval-start revision; process-local owner/global epochs additionally prevent +promotion or reads across same-process invalidation. No wall-clock memo establishes +governance freshness. A read linearizes at its authoritative database revision snapshot; +it does not lock out a later review or retract an answer already delivered. + +In-flight answer coalescing also includes the fresh revision and local owner/global +invalidation epochs in its process-only key. After waiting for an existing promise, the +waiter rechecks that identity before delivery. A changed or unavailable revision/epoch +causes one independent current-state origination; it never repeatedly rejoins the stale +promise or replaces another caller's current origination. Ordinary identical requests +still coalesce. Missing revision availability disables coalescing as well as cache hits. + +Source-review RPCs atomically update the document metadata. The existing route clears +owner-local caches, or all scopes for a public source, after a successful review. +Deletion remains an optimization: returned errors and exceptions are reported without +provider payloads, and successful deletion is never proof of freshness. Cache-replacement +deletion errors stop the insert. These controls do not change the review's success +response when the review itself committed successfully. + +An unavailable, failed or malformed revision RPC permits no cache read/write. It never +falls back to the old latest-row stamp. The `rag-cache-v26` dependency namespace also +prevents upgraded processes from reading v25 and earlier rows. Empty scopes return a +deterministic digest distinct from a failed read. Scope hashing includes all document +statuses, so decommissioning, loss of access and membership changes cannot disappear +behind an unrelated latest indexed document. Full metadata inclusion conservatively +invalidates caches after unrelated document updates. + +The candidate snapshot namespaces and public-write proofs remain required. Anonymous +answer caching, `skipCache`, original-section loaders, active pilot budgets, trusted +reviewed-policy loaders/states and conflicts retain their existing exclusions. The +revision is a freshness mechanism, with no governance ranking penalty or boost. + +## Application and database compatibility + +The draft SQL can precede the application safely. Application code deployed before the +RPC remains cold until the compatible SQL exists. In a mixed deployment old application +replicas still use the old unsafe stamp. An approved rollout must disable the legacy +answer/search cache TTLs and drain old replicas and their in-flight work before claiming +the revocation fence is active; replace every old replica before re-enabling caches. +Rolling back to old code also requires disabled caches. Neither the local fix nor the +draft migration grants deployment or live database authority. + +Each revision has O(number of scoped documents) database read/hash work and a bounded +response. Local hits add an authoritative read; shared hits add before/after reads. +Coalescing adds a read before joining and a check after waiting. No +production query-cost, latency, corpus-size or hosted behavior measurement is claimed. +An approved production rollout should measure those costs before cache re-enablement. +The ordinary eligible local-answer hit currently performs three scope-revision RPCs +(in-flight identity, request-start revision, local-hit fence); a joined waiter performs +two (before joining and after waiting). These are deliberate fresh reads, with no claim +that a scope hash is constant-time database work. + +## Maintained evidence and limits + +`tests/rag-cache-governance-revocation.test.ts` uses synthetic, filter-matching storage +with actual cache modules and review-route composition. It covers governance transitions, +retained cross-replica local/shared answer/search entries, returned delete errors, stale +request revisions, in-flight reads/writes, ordinary hits, the fresh-metadata refusal +control and fail-cold compatibility. The maintained cache, snapshot, owner, reviewed +policy and ranking-contract suites provide adjacent regression coverage. Actual SQL +scope/hash/grant behavior requires the isolated database probe and migration replay; +the JavaScript mock is not evidence of those database properties. + +RAG impact: no retrieval/ranking/ordering behaviour change — cached artifact identity +and freshness change; retrieval inputs, scoring, comparators, source selection and +generation prompts remain unchanged. A revoked cached artifact now misses. Offline +tests do not establish hosted cache behavior, provider quality or clinical acceptance. +No provider canary, live review, migration application or deployment is implied. diff --git a/docs/rag-injection-threat-model.md b/docs/rag-injection-threat-model.md index 91efcbbb56..6bc6adeace 100644 --- a/docs/rag-injection-threat-model.md +++ b/docs/rag-injection-threat-model.md @@ -12,7 +12,7 @@ without checking those implementation-status sections. ## 1. What document-derived text actually reaches the prompt -The answer prompt is assembled by `buildRagSourceBlock` ([`rag-source-block.ts:864`](../src/lib/rag/rag-source-block.ts)), consumed inside `buildAnswerInput` ([`rag.ts:3087`](../src/lib/rag/rag.ts)) under a plain `Sources:` header, alongside the system prompt `answerInstructions` ([`rag-answer-instructions.ts:8`](../src/lib/rag/rag-answer-instructions.ts)). Two content defenses exist: +The answer prompt is assembled by `buildRagSourceBlock` ([`rag-source-block.ts:864`](../src/lib/rag/rag-source-block.ts)), consumed inside `buildAnswerInput` ([`rag.ts:3034`](../src/lib/rag/rag.ts)) under a plain `Sources:` header, alongside the system prompt `answerInstructions` ([`rag-answer-instructions.ts:8`](../src/lib/rag/rag-answer-instructions.ts)). Two content defenses exist: - **`neutralizePromptInstructions`** ([`source-text-sanitizer.ts:444`](../src/lib/source-text-sanitizer.ts)) — six sequential regex `.replace()` calls that blank a fixed set of English injection idioms. - **`fenceSourceEvidence`** ([`source-text-sanitizer.ts:498`](../src/lib/source-text-sanitizer.ts)) — wraps text in `<<>> … <<>>` sentinels; `escapeEvidenceFenceSentinels` ([`:491`](../src/lib/source-text-sanitizer.ts)) defuses forged sentinels _inside_ the fenced text. diff --git a/docs/reindex-runbook.md b/docs/reindex-runbook.md index b39a557095..a01d2de3b0 100644 --- a/docs/reindex-runbook.md +++ b/docs/reindex-runbook.md @@ -71,6 +71,58 @@ The preflight response also includes active job counts and job metadata for oper - Do not use `--force-large-import` unless Supabase health is stable and you have intentionally chosen a larger import wave. - Do not re-index to fix "OCR corruption" or to add table/heading-aware chunking without new evidence. Both were measured negligible/neutral on 2026-07-08 (`noisy_unit_rate` is a saturated visual-coverage metric, not corruption; heading/table-aware chunking already exists and chunks are well-bounded). See `docs/chunking-ocr-reindex-lever-finding-2026-07-08.md`. +## Recovery transaction and draft rollout + +The recovery application now requires `recover_ingestion_job(uuid,text,jsonb)` from +`20261018082910_audit_durable_recovery_pairing_and_cache_fences.sql`. This migration is a +draft: merge only inside the owner's specifically approved database-change window. +Merging database migrations to main applies them to production automatically. + +Apply the SQL before running the new recovery application. An application ahead of SQL +fails its RPC call before changing the document or queue. The old recovery application +must not be used after rollout: it retains obsolete lock and destructive-reset semantics. +Do not roll back to it as an operational workaround. + +Both `recover:ingestion` and the consolidated `reindex` pipeline use the shared RPC-only +application helper. Recovery uses the same current document-authority lock and sorted node/Edge job locks as +worker claims. It rechecks the observed owner, document status, job status, lease token, +lease expiry and legacy timestamp while holding those locks through commit. Current leases +are left alone. Expired final attempts and failed jobs that were never leased requeue +without manufacturing a processing lease. All generations, pages, image references, +artifact families, metadata and counts remain intact; the next worker claim creates a +fresh write generation. Retained obsolete generations use the existing generation-cleanup +procedure after successful indexing, not the recovery command. + +`managed_recovery_required` means immutable managed-generation evidence exists. Leave it +held and use the specialist managed-ingestion recovery procedure; do not delete evidence or +switch to `reset_document_index`. `authority_unavailable` also leaves everything intact. +A changed snapshot is skipped. Re-read the preview after resolving the reason. A lost RPC +response can be retried: either the whole transaction rolled back or its committed pending +job is left to the worker. No partial document reset requires compensation. + +The same draft adds `cme_remove_evidence_v2(uuid,uuid,text)` so an application ahead of SQL +refuses before mutation, while the corrected original RPC remains compatible with old callers. +CME removal commits an owner/object-specific cleanup row with the +privacy tombstone, backfills existing tombstones and preserves that row on retry, including +completed cleanup. Storage/janitor replay is idempotent. Teaching supervision writes lock +pairings before authorization; confirmations lock sorted pairings before sorted entries. +An old supervisor's confirmation admitted before a reassignment commits remains historical; +if reassignment obtains the pairing first, the former supervisor's confirmation refuses. +`work_move_team_v1(uuid,uuid,uuid,uuid)` makes a new application ahead of SQL refuse +before moving. Whole-team supervisor revocation is transactional with a team move; a failed revocation +rolls the move back. Concurrent grants validate the current hospital under the team lock. + +The cache revision RPC hashes the entire exact owner/public/document scope, including every +status and governance metadata. SQL first remains compatible with old cache clients; new +clients before SQL fail cold. Each scope read costs a document scan/hash, so measure realistic +corpus latency before approval. No retrieved text is returned by this RPC. + +Rollback requires a reviewed forward migration and compatible application version. Preserve +cleanup obligations and completed Teaching history; do not edit applied migrations, reseal +hashes or restore the obsolete recovery path. Isolated reduced PostgreSQL function probes +and mocked application tests do not replace full Supabase migration replay, Storage/RLS, +or hosted validation, which remain separate gates. + ## Recommended recovery defaults ```env diff --git a/docs/scripts-index.md b/docs/scripts-index.md index a0c933e126..c7d9feb346 100644 --- a/docs/scripts-index.md +++ b/docs/scripts-index.md @@ -1,6 +1,6 @@ # Scripts index -Curated map of `scripts/` (420 files) and the `package.json` script surface (331 entries), +Curated map of `scripts/` (426 files) and the `package.json` script surface (331 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/superpowers/plans/2026-10-17-full-deep-remediation.md b/docs/superpowers/plans/2026-10-17-full-deep-remediation.md new file mode 100644 index 0000000000..fdd384e2ed --- /dev/null +++ b/docs/superpowers/plans/2026-10-17-full-deep-remediation.md @@ -0,0 +1,309 @@ +# Full Deep Audit Remediation Implementation Plan + +> **For agentic workers:** Use the approved parallel subagent method in this plan, with the repository's instruction hierarchy and shared-check coordinator. Do not recursively delegate or publish changes. + +**Goal:** Correct the audit's reproducible defects, investigate retained leads, and preserve explicit clinical/privacy/release conditions that code cannot legitimately approve. + +**Architecture:** Six isolated implementation lanes share one pinned baseline and disjoint file ownership. The controller integrates patches in dependency order, owns expensive/shared checks, and assigns independent review to stable integrated changes. Existing APIs, owner/role checks, clinical safeguards and current lease protocols remain authoritative. + +**Tech Stack:** Node 24/npm 11, Next 16/React 19, TypeScript 6, Vitest 5, PostgreSQL/Supabase SQL, Python worker locks. + +**Spec:** `docs/audit/2026-10-17-full-deep-remediation-spec.md`. + +## Global constraints + +- Original baseline `b0edabf0b73a806535aa3289e5de8023cf39997f`; root integration checkout `/workspace/PsychSift`, initial branch `work`, initially clean source. The separately authorised current-main candidate and later evidence are recorded below. +- Agent worktrees: `/workspace/psychsift-remediation/{identity,workflows,cache,database,clinical,delivery}`. No agent edits another worktree or the integration checkout. +- One file writer. Database lane alone owns `supabase/schema.sql` and draft migrations. Lead owns organisation-map integration, issue-inbox metadata, plan/receipt and final verification. +- Application fixes are authorised; production/provider actions, approval fabrication, push/merge/deployment and commits are not part of this implementation stage. Keep recoverable local patches/checkpoints. +- No relaxed tests, auth, RLS, source governance, query boundaries, clinical safety or release conditions. Do not edit applied migrations or reseal migration hashes. +- Read root instructions, relevant local instructions and installed Next documentation before editing Next code. RAG protected-surface notification has been issued; no ranking comparator/prompt/evaluation rewrite. +- Reuse attributable baseline evidence for unchanged code. New regression tests must fail against baseline for the intended reason, then pass after correction. +- The controller runs all suites/install/build commands in sequence unless it explicitly grants one of the coordinator's two focused leases. Agents prepare test commands and fixtures without starting shared checks unannounced. +- No new dependency or lock installation during active repository checks. Inspect hooks before setup. No permanent watchers or other-project server changes. + +## Review focus + +1. An old account's success, failure, rollback, queued write or timer must not affect the next account; identity tests cover each owning lane's live consumer. +2. A revocation or lost response must not leave clinical evidence cache-valid or private bytes without durable cleanup intent; actual SQL/cache tests cover returned-error and process-interruption boundaries. +3. Concurrent identity/lease changes must linearize with dependent writes; database tests cover both orderings and ownership loss. +4. Unsupported/missing clinical approvals must remain unavailable rather than be inferred from tests or copy changes; clinical lane tests gate exposed dose advice and staff projections. +5. Dates and instants must round-trip through selected zones, full pay intervals and DST boundaries; workflow regressions cover Sydney/Perth and fractional-offset zones. + +## Parallel waves and shared interfaces + +Wave 1: lanes investigate and prepare regression tests on the baseline; lead schedules red runs. Wave 2: all six implement disjoint fixes; controller runs focused green checks and integrates stable patches. Wave 3: independent reviewers inspect critical integrated changes and challenge negative/positive controls. Wave 4: one coordinated broad offline verification, source/build/domain checks, closure ledger and handoff. + +Existing public hook/component/repository exports stay compatible unless the implementing lane records a necessary interface change before editing consumers. Any new SQL RPC/schema dependency is handed to the database lane and integrated before its TypeScript consumer. No speculative cross-lane API is assumed. + +## Lane A — Account state and delayed effects (`identity`) + +**Owns:** F01, F10; investigate HY01/HY04. Existing `src/components/account-data-provider.tsx`, `src/components/roster/use-roster-settings.ts`, `use-roster-shifts.ts`, `roster-settings-page.tsx`, related user-scoped Roster hooks and identity regressions. Admin pay screen belongs to lane B. + +- [x] Reproduce late favourite membership success, stale error/401/rollback, direct mounted A→existing-B Roster settings/shifts, and A's pending delete under B. +- [x] Read current AuthProvider epoch/event/reset contracts and working Teaching request guards; preserve hook signatures and same-account queues/undo. +- [x] Fence accepted state, request completions and queued/timed effects by captured/current identity; cancel pending deletion before dispatch after a switch. +- [x] Investigate additional live Admin/leave/share consumers; prove or reject each lead rather than infer failure from a pattern. +- [x] Add maintained regressions from the actual-module audit probes plus positive same-account/repeated-write controls; provide exact focused commands. + +Acceptance: B never displays/commits A state; no A delayed destructive intent dispatches under B; stale A failures cannot expire B; same-account save/undo remains correct. No server owner-filter relaxation. + +## Lane B — Work dates, pay completeness and truthful feedback (`workflows`) + +**Owns:** F04, F11, F12. `src/components/roster/roster-month-page.tsx` and merged-source presentation, `src/components/work-screens/admin/pay-page.tsx`, date-range caller contracts, `src/lib/open-shifts/{mine,posted}.ts`, `src/components/teaching/organise-model.ts`, relevant cover/time conversions and tests. Do not edit lane A's shared Roster hooks without handoff. + +- [x] Add failing component test for two 8-hour shifts on 22/28 September with 17 October 2026 now: preceding full fortnight must prefill 16, not 8 hours. +- [x] Request explicit date coverage for all displayed pay periods using existing hook options; preserve custom fortnight anchors and advisory-only payroll copy. +- [x] Add failing Sydney tests: midnight–08:00 counts 8 hours; outside seven local days excluded; room-only Teaching edit preserves exact instant. +- [x] Use existing selected-zone conversions and test Perth, Sydney daylight/standard time, fractional offset and invalid DST input where supported. Trace additional fixed offsets before changing intentional jurisdiction logic. +- [x] Add failed rotation/course-booking warning and targeted retry while preserving successful data and truthful empty/loading states. + +Acceptance: date/instant and completeness contracts hold; each unavailable source is disclosed without erasing successful source data. Run owning DOM/pure helpers and selector-chosen browser proof when available. + +## Lane C — Source revocation and cache freshness (`cache`) + +**Owns:** F13. `src/lib/rag/rag-cache.ts`, effective review-route invalidation, related cache/answer tests and docs. SQL changes go only through lane D. + +- [x] Reproduce both stale local reuse and same-process shared-delete returned-error reuse from the two audit tests; retain a fresh-metadata refusal control. +- [x] Design a durable governance-sensitive cache identity/read fence compatible with current APIs and legacy/candidate snapshots; record exact interfaces before coding. +- [x] Prevent local/shared answer and search reuse after eligibility changes, including failed deletion and in-flight writes; avoid relying solely on fire-and-forget deletion or wall-clock TTL. +- [x] Keep anonymous/skipCache/original-section/pilot/reviewed-loader exclusions intact; no governance ranking penalties or comparator changes. +- [x] Add current-to-outdated/decommissioned, matching shared/local, ordinary hit, returned error and concurrent/in-flight negative cases; disclose any live canary condition still unrun. + +Acceptance: revocation is visible before a new answer can reuse old evidence, not merely when a deletion eventually succeeds. Code/SQL deployment compatibility must fail safely. Qualified clinical and hosted proof remain distinct. + +## Lane D — Durable cleanup, recovery leases and concurrent team records (`database`) + +**Owns:** F02, F03, F14; investigate HY05/HY06. Sole SQL/schema writer. Owns `src/lib/cme/evidence-repository.ts`, cleanup RPC/consumer contracts, `scripts/recover-ingestion-queue.ts`, `src/lib/ingestion-recovery.ts`, relevant current lease/reset SQL, Teaching supervision SQL, work-role team-move transaction if justified, and DB tests/runbook changes. + +- [x] Reproduce postcommit lost-response/removal retry, obsolete recovery-lock/lease counterexample and Teaching empty-pairing reassignment vs old-registrar insert. +- [x] Record current database interfaces and create new draft migration(s) with the installed CLI where available; never alter applied migration/hash history. +- [x] Make private-object cleanup intent atomic with tombstone and retry idempotent; cleanup remains scoped to the exact owner/object. +- [x] Align destructive recovery with current lease ownership and fenced resets, including never-leased/exhausted/currently-running states; do not reset artifacts without holding the effective authority. +- [x] Serialize pairing authorization/insertion against identity/date changes with a consistent lock order; preserve normal logging, reassignment, confirmation and bounded counts. +- [x] Assess team move + supervisor revocation and confirmation/reassignment leads; fix established consistency gaps without activating dormant assessment access. +- [x] Prepare isolated PostgreSQL current-function concurrency/failure tests, not source-string-only success. Lead owns services and execution. Update runbooks with migration/application compatibility and rollout prerequisites. + +Acceptance: interrupted removal retains a durable cleanup obligation; recovery cannot mutate a newer worker attempt; concurrent logs are attributed to the authorized pairing; state changes are idempotent and owner-bound. Full Supabase/migration replay remains a separately recorded gate if unavailable. Drafting SQL does not authorise live application. + +## Lane E — Clinical and real-staff release boundaries (`clinical`) + +**Owns:** F07, F15, HY03; F06 external-evidence handoff. Physiological calculator UI/governance inventory, hospital sick endpoint, short Factsheet projection policy/context and their tests/docs. No SQL writes or fabricated approvals. + +- [x] Add held/enabled production hospital-sick route regressions: held must refuse without invoking the real reader, enabled retains auth/role/hospital scope. +- [x] Preserve the broad PIA real-staff hold unless an authoritative separate approval exists; do not invent it. +- [x] Exposed dose tools must not imply validated clinical recommendations without dedicated review. Preserve legitimate reference calculations with explicit formula/source/limitations where supported, but withhold unapproved dose/management guidance using the established policy. +- [x] Put physiological algorithms in explicit source/review inventory and drift tests, distinguishing engineering arithmetic from medical validation; do not label sources/reviewers approved. +- [x] Preserve Factsheet demonstration-use and content-month context through adapter projection without asserting `locally_reviewed` itself is false. Encode only the supported policy; record unresolved owner interpretation. +- [x] Produce exact F06 approval/evidence checklist and affected scope. Keep its four pending/partial statuses and validator unchanged until genuine authorised evidence exists. + +Acceptance: held real-staff path never reads named sick data; unvalidated dose/management advice is not presented as clinically approved; provenance and limitations are inspectable; no misleading evidence status is generated. + +## Lane F — Delivery inputs, optional native lock and operator guidance (`delivery`) + +**Owns:** F08, F09, F16, AD01. `railway.worker.json`, runtime-input tests, optional cloud Python lock/source/generator and its docs/tests, `docs/observability-slos.md`. No root npm dependency bump without a bounded compatibility case and controller setup scheduling. + +- [x] Add watch-selection tests for isolated model manifest and verifier edits; include both copied runtime integrity inputs in worker watch patterns. +- [x] Regenerate the opt-in cloud Python lock with a compatible Cython/quicksectx closure using native tooling and preserved hashes; validate exact clean installation/pip check where feasible. Do not infer default Python 3.11 worker failure. +- [x] Align SLO runbook signals, definitions and actions with current alert code; distinguish budget-breach rate from p95 and configuration from delivered notifications. +- [x] Check a compatible upstream path for AD01. Do not apply npm's unsafe downgrade or force/legacy resolver options. If no compatible safe fix exists, retain the precise unreachable-CLI mitigation/monitoring recommendation. +- [x] Provide focused contract/syntax/install verification and rollback/compatibility notes; no Railway/provider mutation. + +## Controller-owned integration and privacy diagnostics + +**Owns:** F05, shared organisation/docs inventory, local task receipt, issue-inbox request, final change/closure ledger and integrated checks. + +- [x] Minimise upload diagnostics to safe content-free codes/counts and replace raw fatal error webhook/log payloads with established sanitised diagnostics. Test synthetic filename/provider-shaped errors; never send a real alert. +- [x] Maintain one closure row per F01–F16 plus HY01–HY07/AD01: implemented/tested, disproved, external approval required, or verification blocked. Never count a proposed or disabled feature as clinically validated. +- [x] Integrate disjoint patches only after checking ownership, source drift and tests; resolve shared interfaces serially and add organisation-map rules for new files. +- [x] Run independent security/data and clinical/product reviews against the integrated stable diff. Implement corrections and rerun only affected checks. +- [x] Run one appropriate broad offline gate for this multi-domain diff (full static set, lint, nonincremental typecheck, complete selected unit suite), then unique build/native/DB/browser gates selected by changed boundaries. Keep the known missing-origin-main baseline failure separate; never fabricate a remote ref. +- [x] Run `npm run ensure` before any actual app/browser QA. Use a permitted browser if available; otherwise record unverified rendered/assistive coverage without claiming success. +- [x] Preserve the original audit and export a local-only task receipt/checkpoint; do not update external Notion/GitHub or publish findings. + +## Completion gates + +- [x] Every confirmed code/configuration defect has an integrated correction, meaningful regression and independent review, or a precise unresolved technical blocker. +- [x] Unverified/latent leads have a disposition supported by evidence; F06 external approvals remain truthful. +- [x] Required local checks are complete or precisely blocked, with actual outputs and no orphaned owned services. +- [x] Final diff is coherent and reviewable in `/workspace/PsychSift`; no commits, pushes, merges, deployment or live SQL were performed. + +## Acceptance checkpoint + +All six candidates and independent review corrections are integrated locally. Additional bounded +acceptance fixes cover Node/Edge startup imports, supported Sentry scope binding, and Linux +coordinator terminal-state/thread liveness. Actual thread/zombie fixtures preserve live leases; +56 coordinator tests pass. The actual local Next server starts and confirms this project, and +phone/desktop calculator-hold checks plus PHQ-9 interaction pass. The temporary dev server was +stopped. Combined lint, corrected nonincremental source types, worker/native/domain checks +and the isolated production build are complete. The full unit execution recorded 32,171 passed, +13 failed and 2 skipped; complete affected-file followthrough addresses nine failures, leaving +four drift/seal/reference cases blocked. The full selected Chromium run completed: 764 passed, one iOS-only skipped, zero failures. +The production listener and active owned browser processes stopped after completion. + +The controller authorized a bounded final ownership handoff in the integration checkout: the +database reviewer corrected current-lease type declarations and its typed fixture; the clinical +reviewer owned only the omitted bounds test; the cache reviewer owned only three old cache test +fixtures. These supersede the initial worktree-only restriction for those named files. No two +writers overlapped, runtime source stayed frozen during the full unit execution, and the final +86-path implementation fingerprint includes all test-only followthrough. Independent reviewers +challenged the fixture changes and their positive controls. + +The first isolated production build compiled but exhausted the TypeScript child's default heap. +One retry inherited the repository's normal 8 GB setting and completed without skipping checks. +The exact pinned Supabase image pull failed Docker VFS registration for insufficient storage; +no catalog manifest was regenerated. Specific new migration-hash approval was pending at the initial handoff and was supplied for +the 2026-10-18 continuation below. See the +[results handoff](../../audit/2026-10-17-full-deep-remediation-results.md) and session-local +execution register for exact outcomes and unresolved approval/environment conditions. + +## Authorized continuation — 2026-10-18 + +- [x] Record the owner's specific new-entry approval, append only the exact requested draft hash, + prove the 317 previous entries unchanged, and pass the complete immutability guard/test file. +- [x] Independently inspect the storage blocker and identify only disposable task-owned caches. +- [x] Remove only the three webpack caches, proving retained production artifact hashes unchanged. +- [x] Complete one pinned-image retry after the material capacity change; perform genuine local + catalog replay if the image becomes available, otherwise preserve the precise remaining blocker. +- [x] Obtain the required read-only GitHub confirmation, fetch genuine `origin/main`, and pass + all 18 package-parity owning tests plus default package/receipt and comparison checks. +- [x] Reconcile the current source fingerprint, evidence, task receipt and remaining owner conditions. + +The earlier pending-seal and four-failure statements describe the initial handoff. The approved +seal resolves its two owning failures; no approval to apply SQL, reseal old entries, publish or +deploy was given. Clinical/privacy approvals and provider-backed evaluation still need genuine +authority/evidence; do not manufacture them to mark the plan complete. + +Continuation result: the capacity retry failed after 208.4 seconds with a final-layer no-space +error. No image or replay container was created. The storage blocker remains; no third blind +attempt or Docker configuration change was made. The owner-approved seal and genuine comparison +checks pass, leaving one original unit failure blocked: drift-manifest freshness. Fetched main +is 18 commits ahead of the assessed HEAD; publication/P00 base reconciliation is separate and +was not performed. The original application/test/SQL inputs and all retained +runtime artifacts remain unchanged; broad passing checks were not repeated without cause. + +## Current-main reconciliation continuation — 2026-10-18 + +The owner explicitly authorised resolving current-main reconciliation. The controller created a +separate candidate at `/workspace/psychsift-remediation/current-main` on genuine main +`54559adabe5b51396c2d1c0eb395f2cf79b43fbf`. The original root tree at b0edabf and its 87 frozen +implementation paths remain preserved. This supersedes the earlier statement that fetched +main was assessed but not integrated; it does not rewrite the initial handoff or formal RAG +programme history/receipts. + +- [x] Apply the preserved 103-path remediation patch on the isolated current-main candidate; + resolve four text conflicts and the calculator-page semantic mismatch without restoring + withheld functionality. Keep main's stable favourites behavior plus account/epoch fences, + the whole-panel owner hide decision, and unsigned clinical-content restrictions. +- [x] Preserve the 319-entry migration union: all 318 main seals and all 318 prior remediation + seals remain, zero existing hash changes, and the unchanged specifically approved draft + retains SHA-256 `f0667fed9f0294c1f5ba40252d11ee66e6de07be340a1ec789e0503a1192f2ac`. + No live SQL was applied. Regenerate the native static content/inventory artifacts; exactly eight + Factsheet content/lineage records change, with eligibility/status fields unchanged. +- [x] Independently challenge the bounded overlap resolution and preserve the actual evidence + limits. Source agreement is distinct from tests, full replay, clinical review and model-route + telemetry; no unavailable specialist execution is inferred. +- [x] Establish AD01's candidate-specific resolution using main's unchanged argparse 2.0.1 + override. Actual installed Mammoth 1.13.0/argparse 2.0.1 proof passes all 13 CLI, conversion, + library and production-closure cases; production-only npm audit reports zero vulnerabilities. + The earlier original-baseline no-compatible-fix finding is preserved as historical. +- [x] Run one complete current-main unit/DOM execution: 32,414 passed, three failed, two skipped + of 32,419 in 820.298 seconds. Preserve that failed raw result. Diagnose two failures as the + npm 11.9.0 host failing main's genuine npm 11.17.0 bootstrap requirement; the remaining + failure is actual drift-manifest freshness. +- [x] Install integrity-verified genuine npm 11.17.0 in a private prefix, complete exact-lock offline + installation and installed-tree parity, then pass all 22 cases in the complete Cloud platform + owning file through supported CLOUD_PLATFORM_NPM_HOME. Repeat the 13-case installed + dependency proof successfully after installation. Required package versions stay unchanged; + seven optional WASM-only locations are omitted. No wrapper lies about npm's version and + no assertion/source safeguard is relaxed. This followthrough is not a green full-suite rerun. +- [x] Complete fresh repository lint (139.382 seconds), nonincremental source typecheck + (102.611 seconds), and a fresh isolated production build. Preserve the first browser attempt's + private ES-module global-setup failure before cases, then correct only that private harness. +- [x] Reuse that exact production artifact for the corrected complete selected ui-tools/ui-smoke + Chromium owners: 217 passed, zero failed/skipped/flaky; wrapper 363.291 seconds and + Playwright 360.299 seconds. Keep existing quarantine/mockup exclusions explicit. Verify + ensure/project identity before journeys, all 3,584 non-cache build files unchanged across + retry, and the actual production client-bundle secret-marker check passing. This does not + extend to physical iOS/PWA, unselected engines or hosted production. +- [x] Verify same-image rootless prerequisites privately: exact OCI index/child/config and all + 29 layer digests, trusted signed Debian/package umoci acquisition, ordered uncompressed + diff IDs, and secure rootless unpack (exit 0). Preserve both earlier Docker/VFS failures. +- [x] Complete actual same-image native bootstrap and baseline schema/catalog comparison with + zero differences from genuine main, then implement and execute the maintained optional + rootless generator against the integrated schema. Corrected native run passed in 42.420 + seconds; 335-function snapshot equals the independent full mirror, guest cleanup is 0/0, + supervisor exit 0, no forced stop and owned run removed. Actual manifest hash is + `06bf813db741c9adb7bcbf52fc1545b3541944deee47ff094f3876be6d8fd280`; its schema hash is + `5a095aebcb31d373f9ab80243a93ce47dd8546e136d4f154a7961f73453bff1e`. Preserve default + Docker, native generator identity, deep freshness, exact SQL/hashes and atomic publication + after confirmed cleanup. Local full-catalog/stale-manifest verification is now closed. +- [x] Preserve the first maintained native attempt's readonly-Nix cleanup failure and unchanged + old manifest. Correct only owned directory cleanup, never external symlinks or image SQL. + Preserve the 134-pass/three-failure focused run (stale drift and two umask-dependent fixture + modes); explicit fixture chmod sets exact intended modes without weakening controls. + Complete final owning files passed 137/137: CI cache 82, drift detection 21, deep freshness + eight and new rootless 26. Together with Cloud 22/22, all three raw full-suite failures are + addressed by complete owning followthrough; no fresh green full-suite rerun is asserted. +- [x] Pass post-backend repository lint (2.909 seconds), migration-role and organisation checks. + Fresh nonincremental post-backend typecheck passed in 101.327 seconds after correcting + the project NODE_ENV environment type and process-handle typing. Preserve that first failure. + Exact final-source native replay passed again in 42.420 seconds with confirmed cleanup, and + all four owning files passed 137/137 again. The earlier accepted manifest is preserved; only + generation time and replay duration changed. Guest environment isolation remains unchanged. +- [x] Pass all five unchanged maintained SQL files on the actual full mirror and all 319 sealed + migrations in a separate fresh manual chain with one transaction per file. Preserve the + earlier autocommit-harness failure and correct its transaction contract, not immutable SQL. + Native strict chain/mirror comparison reports no divergence. Effective ACLs verify eight + postgres owners, 16 anon/authenticated EXECUTE denials and eight service_role grants; + both SQL waves shut down gracefully at 0/0. CLI/emulator, Auth HTTP, actual Storage service and deployed behavior + remain unverified. +- [ ] Obtain operator evidence for the reserved platform role's default-privilege posture: two + immutable role/probe warnings skip that proof. Retain the corrected chain's 14 nested-BEGIN + and 14 wrapper-COMMIT warnings; zero SET LOCAL-outside-transaction warnings were seen. + Passing SQL files and actual function ACLs do not make this warning-free or close that gap. +- [ ] Obtain the four genuine privacy/provider obligations, qualified clinical review, applicable + provider-evaluation authority/evidence and hosted operational acceptance. Retain all covered + calculator and real-staff holds until those actual conditions are satisfied. + +### Final acceptance continuation — 2026-10-18 + +- [x] Execute the final complete offline unit selection once: 32,442 passed, one failed and + two skipped of 32,445 in 814.648 seconds. Preserve that raw result. Correct the one stale + migration-role source-shape expectation after shared replay refactoring; both complete + guard/rootless owning files pass 45/45 and the native migration-role check passes. No + application behavior or reserved-role boundary was weakened; no second full rerun claimed. +- [x] Use available connectors for bounded read-only staging inventory/catalog evidence. + Pin the observed deployment separately from the local candidate. Record an existing + worker/build, no passive latency samples and absent executor/provider access; do not + run the unsafe or revision-mismatched hosted harness. +- [x] Record SF01: confirmed staging future-default ACL gap, not existing-data exposure. + Prepare the separate postgres-only service-default proposal. Independent source review + and a real local nonsuperuser PostgreSQL proof pass, including all 45 privilege checks, + actual allow/deny controls, negative guards, transaction rollback and collateral invariance. + Owned PostgreSQL shutdown is confirmed. No proposal was applied to staging. +- [x] Obtain exact approval and apply the unchanged hashed postgres-only proposal to the freshly + verified Psych Sift Staging target. Separate post-commit catalog verification passed all + eight checks: exact service defaults, browser denial, unchanged global/non-public/other- + creator defaults and existing ACL/RLS/owner metadata across 986 catalog entries. No + migration/history operation or hosted synthetic object creation was performed. +- [ ] Resolve reserved-creator defaults separately through platform authority and a global/non- + public compatibility decision. Preserve immutable SQL and the postgres-only hosted policy. +- [x] Check the connected PsychSift planning pages for genuine approval references; no + qualifying sign-off was supplied by those bounded reads. Do not infer absence everywhere. +- [ ] Obtain the genuine privacy/provider/legal and qualified clinical/staff sign-offs, then + authorized candidate-matched Auth/Storage/RAG/cache/performance and rollout evidence. + Do not clear safe holds, merge, deploy or publish under generic remediation authority. + +The actual installed Mammoth proof remains valid, but its ambient npm 11.9.0 `npm ls` +subprocess attempted an update-notifier lookup that failed with EAI_AGAIN. Preserve this +observed network attempt; the proof is not described as making zero network attempts. +Installation/parity and the Cloud followthrough used genuine npm 11.17.0. + +The exact new execution receipts and raw outputs are in the private session-local controller/ +and current-main/ evidence directories; rootless provenance is under rootless-replay/. The +[results report](../../audit/2026-10-17-full-deep-remediation-results.md#current-main-candidate--2026-10-18) +records the snapshot boundaries. No new score/readiness verdict, current-main task-receipt export, +push, PR, merge, live SQL, deployment or clinical approval is asserted by this documentation +update. Local acceptance remains Blocked. Accepted programme identities/reconciledBase remain +historical; this integration does not reset them or establish formal programme/P00 acceptance. diff --git a/railway.worker.json b/railway.worker.json index cf458e3549..7066c620af 100644 --- a/railway.worker.json +++ b/railway.worker.json @@ -19,6 +19,8 @@ "/src/data/**", "/worker/**", "/eval/docling/requirements.txt", + "/eval/docling/model-artifacts.json", + "/eval/docling/verify-model-artifacts.mjs", "/scripts/build-worker.mjs", "/scripts/check-installed-lock-parity.mjs", "/scripts/check-node-engine.cjs", diff --git a/scripts/check-worker-python-lock.mjs b/scripts/check-worker-python-lock.mjs index 518da92030..c506eaf722 100644 --- a/scripts/check-worker-python-lock.mjs +++ b/scripts/check-worker-python-lock.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** - * check-worker-python-lock — verify that worker/python/requirements.txt is - * in sync with worker/python/requirements.in and is a hashed lockfile. + * check-worker-python-lock — verify that the selected Python runtime's lock + * is in sync with its source input and is a hashed lockfile. */ import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -17,8 +17,8 @@ import { const PYTHON = process.env.PYTHON_BIN?.trim() || (process.platform === "win32" ? "python" : "python3"); const PIP_TOOLS_VERSION = "7.6.0"; -const IN_FILE = "worker/python/requirements.in"; const target = requestedLockTarget(); +const IN_FILE = target.inputFile; function run(cmd, args, opts = {}) { const result = spawnSync(cmd, args, { encoding: "utf8", stdio: "pipe", ...opts }); diff --git a/scripts/generate-drift-manifest.ts b/scripts/generate-drift-manifest.ts index ad01b895a8..ae3439cb3c 100644 --- a/scripts/generate-drift-manifest.ts +++ b/scripts/generate-drift-manifest.ts @@ -1,7 +1,9 @@ import { execFileSync } from "node:child_process"; import { createHash, randomBytes } from "node:crypto"; -import { readFileSync, writeFileSync } from "node:fs"; +import { readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; +import { isDirectEntrypoint } from "./lib/is-entrypoint.mjs"; +import { rootlessOptions, withRootlessReplay, type ReplaySql } from "./lib/drift-rootless"; /** * drift:manifest — regenerates supabase/drift-manifest.json. @@ -11,7 +13,8 @@ import { fileURLToPath } from "node:url"; * captures public.schema_drift_snapshot() from that pristine database. The * result is the expected-state side of `npm run check:drift`. * - * Requires Docker. Never touches the live project. Run this whenever + * Defaults to Docker; an explicit offline rootless backend is available on Linux amd64. + * Never touches the live project. Run this whenever * supabase/schema.sql changes — the manifest embeds schema.sql's sha256 and * both check:drift and tests/drift-detection.test.ts fail while it is stale. * The replay has no supabase_migrations schema, so the manifest's @@ -23,6 +26,10 @@ import { fileURLToPath } from "node:url"; * --port host port for the scratch Postgres (default: ephemeral loopback port) * --container override the scratch container name for concurrent worktrees * --image override the digest-pinned Postgres image reference + * --backend rootless --oci-layout --scratch-root + * --umoci --umoci-sha256 + * --evidence explicit offline rootless replay + * Rootless rejects --keep, --port and --container. Tool/image acquisition is separate. */ const IMAGE_DEFAULT = @@ -77,11 +84,126 @@ function removeOwnedContainer(container: string): void { if (assertOwnedContainer(container)) docker(["rm", "-f", container]); } +export function parseSnapshot(raw: string): Record { + const snapshot = JSON.parse(raw) as Record; + if (!snapshot || Array.isArray(snapshot) || typeof snapshot !== "object") + throw new Error("Replay snapshot must be an object"); + for (const category of [ + "extensions", + "tables", + "views", + "functions", + "indexes", + "policies", + "constraints", + "triggers", + "storage_buckets", + ]) { + if (!Array.isArray(snapshot[category]) || !snapshot[category].length) + throw new Error(`Replay snapshot inventory ${category} is missing or empty`); + } + if ( + (snapshot.functions as unknown[]).length <= 40 || + (snapshot.indexes as unknown[]).length <= 100 || + (snapshot.tables as unknown[]).length <= 30 || + snapshot.snapshot_version !== 2 || + snapshot.migration_history_probe !== "no_history_table" || + !Array.isArray(snapshot.migration_history) || + snapshot.migration_history.length + ) { + throw new Error("Replay snapshot does not satisfy the complete fresh-schema inventory contract"); + } + delete snapshot.captured_at; + return snapshot; +} + +export async function replaySchema( + sql: ReplaySql, + inputs: { rolesSql: string; scaffoldSql: string; schemaSql: string }, +): Promise> { + console.log("Applying role bootstrap (postgres)…"); + await sql("postgres", inputs.rolesSql); + const storageSchemaOwner = ( + await sql( + "postgres", + "select pg_catalog.pg_get_userbyid(nspowner) from pg_catalog.pg_namespace where nspname = 'storage';", + true, + ) + ).trim(); + if (!storageSchemaOwner) throw new Error("bare Supabase image does not expose an owner for the storage schema"); + console.log("Applying storage scaffold as the discovered bare-image storage owner…"); + await sql(storageSchemaOwner, inputs.scaffoldSql); + console.log("Replaying supabase/schema.sql from scratch (postgres)…"); + await sql("postgres", inputs.schemaSql); + return parseSnapshot((await sql("postgres", "select public.schema_drift_snapshot()::text;", true)).trim()); +} + +export function manifestText( + image: string, + schemaSha256: string, + replaySeconds: number, + snapshot: Record, +): string { + return `${JSON.stringify( + { + generated_at: new Date().toISOString(), + generator: "scripts/generate-drift-manifest.ts", + postgres_image: image, + schema_sha256: schemaSha256, + replay_seconds: replaySeconds, + snapshot, + }, + null, + 2, + )}\n`; +} + +export function writeManifestAtomically(output: string, text: string): void { + const temporary = `${output}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`; + try { + writeFileSync(temporary, text, { flag: "wx", mode: 0o644 }); + renameSync(temporary, output); + } finally { + try { + unlinkSync(temporary); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } +} + async function main() { const image = arg("--image") ?? IMAGE_DEFAULT; if (!/@sha256:[a-f0-9]{64}$/i.test(image)) { throw new Error("--image must use an immutable @sha256 digest reference"); } + const backend = arg("--backend") ?? "docker"; + if (backend !== "docker" && backend !== "rootless") throw new Error("--backend must be docker or rootless"); + if (backend === "rootless") { + const options = rootlessOptions(process.argv, image, repoUrl("")); + const inputs = { + schemaSql: readFileSync(repoUrl("supabase/schema.sql"), "utf8"), + rolesSql: readFileSync(repoUrl("supabase/roles.sql"), "utf8"), + scaffoldSql: readFileSync(repoUrl("scripts/sql/drift-replay-scaffold.sql"), "utf8"), + }; + const { normalizedSchemaSha256 } = await import("./check-drift"); + const startedAt = Date.now(); + console.log(`Starting explicit offline rootless replay (${image})…`); + const snapshot = await withRootlessReplay(options, (sql) => replaySchema(sql, inputs)); + const text = manifestText( + image, + normalizedSchemaSha256(inputs.schemaSql), + Math.round((Date.now() - startedAt) / 1000), + snapshot, + ); + writeManifestAtomically(repoUrl("supabase/drift-manifest.json"), text); + const evidence = JSON.parse(readFileSync(options.evidence, "utf8")); + evidence.manifest_sha256 = createHash("sha256").update(text).digest("hex"); + evidence.schema_sha256 = normalizedSchemaSha256(inputs.schemaSql); + writeFileSync(options.evidence, `${JSON.stringify(evidence, null, 2)}\n`, { mode: 0o600 }); + console.log("Wrote supabase/drift-manifest.json from actual rootless replay; private provenance is in --evidence."); + return; + } const requestedPort = arg("--port"); if (requestedPort && (!/^\d+$/.test(requestedPort) || Number(requestedPort) < 1 || Number(requestedPort) > 65535)) { throw new Error("--port must be an integer from 1 to 65535"); @@ -156,55 +278,34 @@ async function main() { throw new Error("scratch Postgres did not remain ready after initialization"); } - const psql = (user: string, sql: string) => - docker( - ["exec", "-i", container, "psql", "-U", user, "-d", "postgres", "-v", "ON_ERROR_STOP=1", "-q", "-f", "-"], - sql, - ); - - console.log("Applying role bootstrap (postgres)…"); - psql("postgres", rolesSql); - const storageSchemaOwner = docker([ - "exec", - "-i", - container, - "psql", - "-U", - "postgres", - "-d", - "postgres", - "-tA", - "-v", - "ON_ERROR_STOP=1", - "-c", - "select pg_catalog.pg_get_userbyid(nspowner) from pg_catalog.pg_namespace where nspname = 'storage';", - ]).trim(); - if (!storageSchemaOwner) { - throw new Error("bare Supabase image does not expose an owner for the storage schema"); - } - console.log("Applying storage scaffold as the discovered bare-image storage owner…"); - psql(storageSchemaOwner, scaffoldSql); - console.log("Replaying supabase/schema.sql from scratch (postgres)…"); - psql("postgres", schemaSql); - const replaySeconds = ((Date.now() - startedAt) / 1000).toFixed(0); + const snapshot = await replaySchema( + async (user, sql, tuples = false) => + docker( + [ + "exec", + "-i", + container, + "psql", + "-U", + user, + "-d", + "postgres", + "-v", + "ON_ERROR_STOP=1", + ...(tuples ? ["-tA"] : ["-q"]), + "-f", + "-", + ], + sql, + ), + { rolesSql, scaffoldSql, schemaSql }, + ); + const replaySeconds = Math.round((Date.now() - startedAt) / 1000); console.log(`Replay complete in ${replaySeconds}s (container start included).`); - - const raw = docker( - ["exec", "-i", container, "psql", "-U", "postgres", "-d", "postgres", "-tA", "-v", "ON_ERROR_STOP=1", "-f", "-"], - "select public.schema_drift_snapshot()::text;", - ).trim(); - const snapshot = JSON.parse(raw) as Record; - delete snapshot.captured_at; - - const manifest = { - generated_at: new Date().toISOString(), - generator: "scripts/generate-drift-manifest.ts", - postgres_image: image, - schema_sha256: normalizedSchemaSha256(schemaSql), - replay_seconds: Number(replaySeconds), - snapshot, - }; - writeFileSync(repoUrl("supabase/drift-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); + writeManifestAtomically( + repoUrl("supabase/drift-manifest.json"), + manifestText(image, normalizedSchemaSha256(schemaSql), replaySeconds, snapshot), + ); console.log("Wrote supabase/drift-manifest.json"); console.log("Next: run `npm run check:drift` against live (needs service-role env)."); } finally { @@ -222,7 +323,9 @@ async function main() { } } -main().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; -}); +if (isDirectEntrypoint(import.meta.url)) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/scripts/generate-worker-python-lock.mjs b/scripts/generate-worker-python-lock.mjs index 792801e2d2..b7b84e7e80 100644 --- a/scripts/generate-worker-python-lock.mjs +++ b/scripts/generate-worker-python-lock.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** - * generate-worker-python-lock — pin worker/python/requirements.txt from - * worker/python/requirements.in using pip-tools. + * generate-worker-python-lock — pin the selected Python runtime's dependency + * input using pip-tools. Target paths live in worker-python-lock-config.mjs. */ import { mkdtempSync, rmSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -12,8 +12,8 @@ import { pythonMajorMinor, requestedLockTarget } from "./worker-python-lock-conf const PYTHON = process.env.PYTHON_BIN?.trim() || (process.platform === "win32" ? "python" : "python3"); const PIP_TOOLS_VERSION = "7.6.0"; -const IN_FILE = "worker/python/requirements.in"; const target = requestedLockTarget(); +const IN_FILE = target.inputFile; function run(cmd, args, opts = {}) { const result = spawnSync(cmd, args, { encoding: "utf8", stdio: "pipe", ...opts }); diff --git a/scripts/lib/drift-rootless.ts b/scripts/lib/drift-rootless.ts new file mode 100644 index 0000000000..3f332bacb6 --- /dev/null +++ b/scripts/lib/drift-rootless.ts @@ -0,0 +1,616 @@ +import { spawn } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { + access, + cp, + lstat, + mkdir, + mkdtemp, + open, + readFile, + readdir, + realpath, + rm, + stat, + statfs, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; +import { constants } from "node:fs"; +import { createGunzip } from "node:zlib"; + +export type ReplaySql = (role: string, sql: string, tuples?: boolean) => Promise; +type Descriptor = { + digest: string; + size: number; + mediaType?: string; + platform?: { os?: string; architecture?: string }; + annotations?: Record; +}; +type ImageConfig = { + architecture: string; + os: string; + rootfs: { diff_ids: string[] }; + config: { Env: string[]; Entrypoint: string[]; Cmd: string[]; WorkingDir?: string }; +}; +export type RootlessOptions = { + image: string; + layout: string; + scratchRoot: string; + umoci: string; + umociSha256: string; + evidence: string; + worktree: string; +}; +const MAX_BYTES = 8 * 1024 ** 3; +const MAX_OUTPUT = 64 * 1024 ** 2; +const sha256 = (value: string | Buffer) => createHash("sha256").update(value).digest("hex"); +const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const containsControl = (value: string) => + [...value].some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127); + +export function rootlessOptions(argv: string[], image: string, worktree: string): RootlessOptions { + for (const flag of ["--keep", "--port", "--container"]) { + if (argv.includes(flag)) throw new Error(`${flag} is not supported by the rootless backend`); + } + const value = (flag: string) => { + const index = argv.indexOf(flag); + const result = index < 0 ? undefined : argv[index + 1]; + if (!result || result.startsWith("--")) throw new Error(`${flag} is required for the rootless backend`); + return result; + }; + const options = { + image, + worktree, + layout: value("--oci-layout"), + scratchRoot: value("--scratch-root"), + umoci: value("--umoci"), + umociSha256: value("--umoci-sha256"), + evidence: value("--evidence"), + }; + if (!/^[a-f0-9]{64}$/.test(options.umociSha256)) throw new Error("--umoci-sha256 must be an exact SHA-256"); + return options; +} + +/** Validate control paths, not legitimate symlinks within the unpacked image. */ +export async function controlPath(input: string, directory: boolean): Promise { + if (!path.isAbsolute(input) || containsControl(input)) + throw new Error("Rootless paths must be absolute control paths"); + const resolved = path.resolve(input); + let current = path.parse(resolved).root; + for (const component of resolved.slice(current.length).split(path.sep).filter(Boolean)) { + current = path.join(current, component); + if ((await lstat(current)).isSymbolicLink()) throw new Error("Rootless control paths must not contain symlinks"); + } + const info = await stat(resolved); + if (directory ? !info.isDirectory() : !info.isFile()) throw new Error("Unexpected rootless control path type"); + return realpath(resolved); +} + +async function hashStream(file: string, decompress = false): Promise<{ digest: string; bytes: number }> { + const source = createReadStream(file); + const stream = decompress ? source.pipe(createGunzip()) : source; + if (decompress) source.on("error", (error) => stream.destroy(error)); + const hash = createHash("sha256"); + let bytes = 0; + for await (const chunk of stream) { + bytes += chunk.length; + if (bytes > MAX_BYTES) { + stream.destroy(); + source.destroy(); + throw new Error("Rootless image stream exceeds the 8 GiB bound"); + } + hash.update(chunk); + } + return { digest: `sha256:${hash.digest("hex")}`, bytes }; +} + +function digestHex(digest: string): string { + if (!/^sha256:[a-f0-9]{64}$/.test(digest)) throw new Error("Unsupported OCI digest"); + return digest.slice(7); +} + +/** Reverify local bytes; a prior acquisition receipt alone cannot authenticate a rootfs. */ +export async function verifyOfflineImage(layout: string, image: string) { + const expected = image.match(/@sha256:([a-f0-9]{64})$/)?.[1]; + if (!expected) throw new Error("Rootless image requires an immutable SHA-256 reference"); + if (JSON.parse(await readFile(path.join(layout, "oci-layout"), "utf8")).imageLayoutVersion !== "1.0.0") { + throw new Error("Unsupported OCI layout version"); + } + const blob = async (descriptor: Descriptor) => { + const file = await controlPath(path.join(layout, "blobs", "sha256", digestHex(descriptor.digest)), false); + const actual = await hashStream(file); + if (actual.digest !== descriptor.digest || actual.bytes !== descriptor.size) + throw new Error("OCI blob digest/size mismatch"); + return file; + }; + const indexFile = await controlPath(path.join(layout, "blobs", "sha256", expected), false); + const topBytes = await readFile(indexFile); + if (sha256(topBytes) !== expected) throw new Error("Pinned OCI index digest mismatch"); + const index = JSON.parse(topBytes.toString()) as { mediaType: string; manifests: Descriptor[] }; + if (index.mediaType !== "application/vnd.oci.image.index.v1+json") + throw new Error("Rootless replay requires the pinned OCI index"); + const candidates = index.manifests.filter( + (item) => item.platform?.os === "linux" && item.platform.architecture === "amd64", + ); + if (candidates.length !== 1) throw new Error("OCI index must identify exactly one linux/amd64 image"); + const selected = candidates[0]; + const manifest = JSON.parse(await readFile(await blob(selected), "utf8")) as { + mediaType: string; + config: Descriptor; + layers: Descriptor[]; + }; + if ( + manifest.mediaType !== "application/vnd.oci.image.manifest.v1+json" || + manifest.config.mediaType !== "application/vnd.oci.image.config.v1+json" + ) { + throw new Error("Unsupported OCI manifest/config media type"); + } + const config = JSON.parse(await readFile(await blob(manifest.config), "utf8")) as ImageConfig; + if ( + config.os !== "linux" || + config.architecture !== "amd64" || + config.rootfs.diff_ids.length !== manifest.layers.length || + !manifest.layers.length + ) { + throw new Error("OCI platform or ordered rootfs descriptors are inconsistent"); + } + const layers = []; + let expandedBytes = 0; + for (const [number, descriptor] of manifest.layers.entries()) { + if (descriptor.mediaType !== "application/vnd.oci.image.layer.v1.tar+gzip") + throw new Error("Unsupported OCI layer media type"); + const file = await blob(descriptor); + const unpacked = await hashStream(file, true); + expandedBytes += unpacked.bytes; + if (unpacked.digest !== config.rootfs.diff_ids[number] || expandedBytes > MAX_BYTES) + throw new Error("OCI rootfs diff ID or expansion bound mismatch"); + layers.push({ digest: descriptor.digest, bytes: descriptor.size, diff_id: unpacked.digest }); + } + const localIndex = JSON.parse(await readFile(path.join(layout, "index.json"), "utf8")) as { manifests: Descriptor[] }; + const references = localIndex.manifests.filter( + (item) => item.digest === selected.digest && item.size === selected.size && item.mediaType === selected.mediaType, + ); + const tag = references.length === 1 ? references[0].annotations?.["org.opencontainers.image.ref.name"] : undefined; + if ( + !tag || + !/^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/.test(tag) || + localIndex.manifests.filter((item) => item.annotations?.["org.opencontainers.image.ref.name"] === tag).length !== 1 + ) + throw new Error("Local OCI layout must have one unambiguous tag for the verified child"); + return { + config, + tag, + index_digest: `sha256:${expected}`, + manifest_digest: selected.digest, + config_digest: manifest.config.digest, + layers, + expanded_bytes: expandedBytes, + }; +} + +export function imageEnvironment(config: ImageConfig): Record { + const environment: Record = {}; + for (const entry of config.config.Env) { + const separator = entry.indexOf("="); + const key = entry.slice(0, separator); + if (separator < 1 || !/^[A-Z_][A-Z0-9_]*$/.test(key) || Object.hasOwn(environment, key) || key === "LD_PRELOAD") + throw new Error("Unsupported image environment"); + environment[key] = entry.slice(separator + 1); + } + if ( + environment.PGDATA !== "/var/lib/postgresql/data" || + environment.POSTGRES_HOST !== "/var/run/postgresql" || + environment.POSTGRES_DB !== "postgres" || + JSON.stringify(config.config.Entrypoint) !== '["docker-entrypoint.sh"]' || + JSON.stringify(config.config.Cmd) !== '["postgres","-D","/etc/postgresql"]' || + (config.config.WorkingDir && config.config.WorkingDir !== "/") + ) + throw new Error("Image bootstrap differs from the supported rootless contract"); + return environment; +} + +/** One shell owns bootstrap, all psql calls and shutdown in the SAME PID namespace. */ +export const ROOTLESS_SUPERVISOR = String.raw` +set -Eeuo pipefail +IFS= read -r POSTGRES_PASSWORD +export POSTGRES_PASSWORD PGPASSWORD="$POSTGRES_PASSWORD" +server_pid="" +cleanup() { + original_status=$? + trap - EXIT INT TERM HUP + pg_status=0 + server_status=0 + if [ -n "$server_pid" ]; then + pg_ctl -D "$PGDATA" -m fast -w -t 20 stop > /tmp/drift-output/cleanup.log 2>&1 || pg_status=$? + if kill -0 "$server_pid" 2>/dev/null; then kill -TERM "$server_pid" 2>/dev/null || true; fi + wait "$server_pid" 2>/dev/null || server_status=$? + fi + printf '%s %s\n' "$pg_status" "$server_status" > /tmp/drift-output/cleanup-status + if [ "$pg_status" -ne 0 ] || [ "$server_status" -ne 0 ]; then exit 1; fi + exit "$original_status" +} +trap cleanup EXIT +trap 'exit 143' TERM HUP +trap 'exit 130' INT +docker-entrypoint.sh postgres -D /etc/postgresql -c listen_addresses= > /tmp/drift-output/server.log 2>&1 & +server_pid=$! +ready=0 +for ((attempt=0; attempt<60; attempt++)); do + kill -0 "$server_pid" 2>/dev/null || exit 1 + if grep -Fq 'PostgreSQL init process complete; ready for start up.' /tmp/drift-output/server.log && pg_isready -h "$POSTGRES_HOST" -U postgres -q; then + ready=$((ready+1)) + else + ready=0 + fi + if [ "$ready" -ge 5 ]; then break; fi + sleep 2 +done +[ "$ready" -ge 5 ] || exit 1 +printf 'ready\n' > /tmp/drift-output/ready +while read -r sequence mode encoded_role encoded_sql; do + [[ "$sequence" =~ ^[0-9]+$ ]] || exit 1 + [[ "$mode" == script || "$mode" == tuples ]] || exit 1 + role=$(printf '%s' "$encoded_role" | base64 -d) + psql_command() { + if [ "$mode" == tuples ]; then + psql -X -h "$POSTGRES_HOST" -U "$role" -d postgres -v ON_ERROR_STOP=1 -tA -f - + else + psql -X -h "$POSTGRES_HOST" -U "$role" -d postgres -v ON_ERROR_STOP=1 -q -f - + fi + } + status=0 + printf '%s' "$encoded_sql" | base64 -d | psql_command > "/tmp/drift-output/$sequence.out" 2> "/tmp/drift-output/$sequence.err" || status=$? + printf '%s\n' "$status" > "/tmp/drift-output/$sequence.status.tmp" + mv "/tmp/drift-output/$sequence.status.tmp" "/tmp/drift-output/$sequence.status" +done +`; + +async function command(executable: string, args: string[], timeout: number): Promise { + await new Promise((resolve, reject) => { + const child = spawn(executable, args, { + env: { PATH: "/usr/bin:/bin", NODE_ENV: "test" }, + stdio: "ignore", + timeout, + killSignal: "SIGKILL", + }); + child.once("error", () => reject(new Error("Rootless tool could not start"))); + child.once("exit", (code) => (code === 0 ? resolve() : reject(new Error("Rootless tool failed or timed out")))); + }); +} + +export type OwnedRunIdentity = { ino: number; dev: number; uid: number; marker: string }; + +/** Only the stopped, identity-checked disposable run may have its directory modes changed. */ +export async function removeOwnedRootlessRun(run: string, expected: OwnedRunIdentity): Promise { + const canonical = await controlPath(run, true); + if (canonical !== run || !/^drift-rootless-[A-Za-z0-9]+$/.test(path.basename(run))) { + throw new Error("Refusing cleanup outside a canonical rootless run"); + } + const flags = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW; + const parent = await open(path.dirname(run), flags); + try { + const pinnedRun = `/proc/self/fd/${parent.fd}/${path.basename(run)}`; + const root = await open(pinnedRun, flags); + try { + const info = await root.stat(); + if ( + info.ino !== expected.ino || + info.dev !== expected.dev || + info.uid !== expected.uid || + info.uid !== process.getuid?.() + ) { + throw new Error("Refusing cleanup: run identity or ownership changed"); + } + const markerPath = `/proc/self/fd/${root.fd}/owner.json`; + const markerInfo = await lstat(markerPath); + if ( + !markerInfo.isFile() || + markerInfo.isSymbolicLink() || + (await readFile(markerPath, "utf8")) !== expected.marker + ) { + throw new Error("Refusing cleanup: run ownership marker changed"); + } + const makeDirectoriesRemovable = async (directory: Awaited>): Promise => { + const current = await directory.stat(); + if (current.dev !== expected.dev || current.uid !== expected.uid) + throw new Error("Refusing cleanup across foreign directory ownership or filesystem"); + // chmod the pinned directory FD, never a path that could resolve through a symlink. + await directory.chmod((current.mode & 0o7777) | 0o700); + const pinned = `/proc/self/fd/${directory.fd}`; + for (const name of await readdir(pinned)) { + const childPath = `${pinned}/${name}`; + const childInfo = await lstat(childPath); + if (!childInfo.isDirectory() || childInfo.isSymbolicLink()) continue; + const child = await open(childPath, flags); + try { + const opened = await child.stat(); + if (opened.ino !== childInfo.ino || opened.dev !== childInfo.dev) + throw new Error("Refusing cleanup: directory changed during inspection"); + await makeDirectoriesRemovable(child); + } finally { + await child.close(); + } + } + }; + await makeDirectoriesRemovable(root); + const final = await lstat(pinnedRun); + if ( + final.isSymbolicLink() || + final.ino !== expected.ino || + final.dev !== expected.dev || + (await readFile(markerPath, "utf8")) !== expected.marker + ) { + throw new Error("Refusing removal: run identity changed"); + } + // fs.rm unlinks symlink entries; only owned directory FDs were chmodded above. + await rm(pinnedRun, { recursive: true }); + } finally { + await root.close(); + } + } finally { + await parent.close(); + } +} + +export async function withRootlessReplay( + options: RootlessOptions, + replay: (sql: ReplaySql) => Promise, +): Promise { + if (process.platform !== "linux" || process.arch !== "x64" || process.getuid?.() === 0) + throw new Error("Rootless replay supports a non-root Linux amd64 host only"); + const layout = await controlPath(options.layout, true); + const scratch = await controlPath(options.scratchRoot, true); + const tool = await controlPath(options.umoci, false); + const evidenceParent = await controlPath(path.dirname(options.evidence), true); + if (!path.isAbsolute(options.evidence) || containsControl(options.evidence)) + throw new Error("Evidence requires an absolute private output path"); + const evidenceFile = path.join(evidenceParent, path.basename(options.evidence)); + try { + await lstat(evidenceFile); + throw new Error("Evidence output already exists"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + const ownership = await stat(scratch); + const evidenceOwnership = await stat(evidenceParent); + if ( + ownership.uid !== process.getuid?.() || + ownership.mode & 0o077 || + evidenceOwnership.uid !== process.getuid?.() || + evidenceOwnership.mode & 0o077 + ) + throw new Error("Scratch and evidence parents must be private and owned by this user"); + if ( + layout === scratch || + layout.startsWith(`${scratch}${path.sep}`) || + scratch.startsWith(`${layout}${path.sep}`) || + evidenceFile.startsWith(`${layout}${path.sep}`) + ) + throw new Error("OCI inputs and scratch parent must not overlap"); + await access(tool, constants.X_OK); + if ((await hashStream(tool)).digest !== `sha256:${options.umociSha256}`) + throw new Error("Trusted umoci binary hash mismatch"); + const verified = await verifyOfflineImage(layout, options.image); + const environment = imageEnvironment(verified.config); + const capacity = await statfs(scratch); + if (capacity.bavail * capacity.bsize < verified.expanded_bytes + 4 * 1024 ** 3) + throw new Error("Insufficient scratch capacity for verified rootless image and 4 GiB reserve"); + const run = await mkdtemp(path.join(scratch, "drift-rootless-")); + const outputRoot = path.join(run, "tmp", "drift-output"); + const nonce = randomBytes(16).toString("hex"); + const marker = JSON.stringify({ worktree: await realpath(options.worktree), uid: process.getuid?.(), nonce, run }); + await writeFile(path.join(run, "owner.json"), marker, { mode: 0o600, flag: "wx" }); + const originalRun = await stat(run); + const evidence: Record = { + backend: "rootless", + image: options.image, + index_digest: verified.index_digest, + manifest_digest: verified.manifest_digest, + config_digest: verified.config_digest, + layers: verified.layers, + umoci_sha256: options.umociSha256, + worktree: options.worktree, + run, + status: "Running", + environment_keys: Object.keys(environment), + environment_values_logged: false, + uid: 100, + gid: 101, + readonly_rootfs: true, + external_network: false, + host_tcp_port: null, + commands: [], + cleanup: "pending", + }; + let child: ReturnType | undefined; + let closed = false; + let exit: Promise | undefined; + let childExit: { code: number | null; signal: NodeJS.Signals | null; spawn_error?: boolean } | undefined; + const waitFor = async (file: string, timeout: number) => { + const started = Date.now(); + while (true) { + try { + return await readFile(file, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if (closed) throw new Error("Rootless supervisor exited before completing replay"); + if (Date.now() - started > timeout) throw new Error("Rootless replay phase timed out"); + await pause(50); + } + }; + let password = ""; + let replayFailed = false; + try { + await command( + tool, + ["unpack", "--rootless", "--image", `${layout}:${verified.tag}`, path.join(run, "bundle")], + 300_000, + ); + const rootfs = path.join(run, "bundle", "rootfs"); + const passwd = await readFile(await controlPath(path.join(rootfs, "etc/passwd"), false), "utf8"); + await controlPath(path.join(rootfs, "etc/postgresql"), true); + await controlPath(path.join(rootfs, "etc/postgresql-custom"), true); + if (!/^postgres:[^:]*:100:101:/m.test(passwd)) + throw new Error("Extracted image postgres UID/GID does not match the proven rootless mapping"); + for (const directory of ["data", "socket", "tmp"]) await mkdir(path.join(run, directory), { mode: 0o700 }); + await mkdir(outputRoot, { mode: 0o700 }); + await cp(path.join(rootfs, "etc/postgresql"), path.join(run, "config"), { + recursive: true, + verbatimSymlinks: true, + }); + await cp(path.join(rootfs, "etc/postgresql-custom"), path.join(run, "custom-config"), { + recursive: true, + verbatimSymlinks: true, + }); + const args = [ + "--unshare-user", + "--uid", + "100", + "--gid", + "101", + "--unshare-pid", + "--unshare-ipc", + "--unshare-uts", + "--unshare-net", + "--ro-bind", + rootfs, + "/", + ]; + for (const [local, guest] of [ + ["data", "/var/lib/postgresql/data"], + ["socket", "/run/postgresql"], + ["tmp", "/tmp"], + ["config", "/etc/postgresql"], + ["custom-config", "/etc/postgresql-custom"], + ]) + args.push("--bind", path.join(run, local), guest); + args.push("--proc", "/proc", "--dev", "/dev", "--clearenv", "--chdir", "/", "--die-with-parent", "--new-session"); + for (const [key, value] of Object.entries(environment)) args.push("--setenv", key, value); + args.push("--", "/bin/bash", "-c", ROOTLESS_SUPERVISOR); + const active = spawn("/usr/bin/bwrap", args, { + env: { PATH: "/usr/bin:/bin", NODE_ENV: "test" }, + stdio: ["pipe", "ignore", "ignore"], + }); + child = active; + exit = new Promise((resolve) => { + active.once("error", () => { + childExit = { code: null, signal: null, spawn_error: true }; + closed = true; + resolve(); + }); + active.once("exit", (code, signal) => { + childExit = { code, signal }; + closed = true; + resolve(); + }); + }); + password = randomBytes(16).toString("hex"); + active.stdin?.on("error", () => { + /* The phase waiter reports process closure. */ + }); + active.stdin?.write(`${password}\n`); + await waitFor(path.join(outputRoot, "ready"), 135_000); + evidence.final_initialization_marker_and_five_readiness_checks = true; + let sequence = 0; + const result = await replay(async (role, sql, tuples = false) => { + if (closed) throw new Error("Rootless database is not running"); + const number = ++sequence; + active.stdin?.write( + `${number} ${tuples ? "tuples" : "script"} ${Buffer.from(role).toString("base64")} ${Buffer.from(sql).toString("base64")}\n`, + ); + const status = await waitFor(path.join(outputRoot, `${number}.status`), 600_000); + (evidence.commands as unknown[]).push({ number, role, sql_sha256: sha256(sql), exit_status: Number(status) }); + if (status.trim() !== "0") throw new Error(`Rootless psql step ${number} failed (raw output is not exported)`); + const output = path.join(outputRoot, `${number}.out`); + if ((await stat(output)).size > MAX_OUTPUT) + throw new Error("Rootless psql output exceeds the bounded capture limit"); + return readFile(output, "utf8"); + }); + evidence.status = "Passed"; + return result; + } catch (error) { + replayFailed = true; + evidence.status = "Failed"; + evidence.reason = + error instanceof Error ? error.message.replaceAll(password || "\0", "[redacted]") : "Rootless replay failed"; + throw error; + } finally { + let cleanupFailed = false; + if (child && exit) { + child.stdin?.end(); + const boundedExit = async (milliseconds: number) => { + let timer: ReturnType | undefined; + try { + await Promise.race([ + exit, + new Promise((resolve) => { + timer = setTimeout(resolve, milliseconds); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + }; + await boundedExit(30_000); + if (!closed) { + evidence.forced_stop = "SIGTERM"; + child.kill("SIGTERM"); + await boundedExit(5_000); + } + if (!closed) { + evidence.forced_stop = "SIGKILL"; + child.kill("SIGKILL"); + await exit; + } + evidence.owned_supervisor_stopped = closed; + evidence.supervisor_exit = childExit; + let cleanupStatus = "unavailable"; + try { + cleanupStatus = (await readFile(path.join(outputRoot, "cleanup-status"), "utf8")).trim(); + } catch { + /* Startup/forced-stop can prevent the trap. */ + } + evidence.guest_cleanup_status = cleanupStatus; + cleanupFailed = Boolean(evidence.forced_stop) || childExit?.code !== 0 || cleanupStatus !== "0 0"; + if (cleanupFailed) evidence.status = "Failed"; + } + const output = outputRoot; + const logs: Record = {}; + for (const name of ["server.log", "cleanup.log"]) { + try { + const bytes = await readFile(path.join(output, name)); + // Native init can create private encryption material. Never export log text. + logs[name] = { bytes: bytes.length, sha256: sha256(bytes), text_exported: false }; + } catch { + /* Failure can precede guest startup. */ + } + } + evidence.logs = logs; + try { + await removeOwnedRootlessRun(run, { ino: originalRun.ino, dev: originalRun.dev, uid: originalRun.uid, marker }); + evidence.cleanup = "owned run removed"; + } catch (error) { + cleanupFailed = true; + evidence.status = "Failed"; + evidence.cleanup = "owned run removal failed or refused"; + evidence.cleanup_error = { + code: (error as NodeJS.ErrnoException).code ?? "OWNERSHIP_OR_CLEANUP", + message: error instanceof Error ? error.message.replaceAll(password || "\0", "[redacted]") : "Cleanup failed", + }; + } + try { + await writeFile(evidenceFile, `${JSON.stringify(evidence, null, 2)}\n`, { mode: 0o600, flag: "wx" }); + } catch (error) { + // A sidecar write failure cannot replace the original SQL/replay exception. + if (!replayFailed) throw error; + console.warn("Could not persist rootless failure evidence; the original replay error is preserved."); + } + if (cleanupFailed && !replayFailed) + throw new Error( + "Rootless cleanup did not confirm graceful shutdown and owned run removal; inspect private evidence", + ); + } +} diff --git a/scripts/recover-ingestion-queue.ts b/scripts/recover-ingestion-queue.ts index 68224c4c0b..77973c2aa5 100644 --- a/scripts/recover-ingestion-queue.ts +++ b/scripts/recover-ingestion-queue.ts @@ -1,4 +1,3 @@ -import { randomUUID } from "node:crypto"; import { loadEnvConfig } from "@next/env"; import type { IngestionRecoveryJob } from "@/lib/ingestion-recovery"; import { confirm } from "./cli-utils"; @@ -17,6 +16,8 @@ type RawJobRow = { document_id: string; status: string | null; locked_at: string | null; + lease_token: string | null; + lease_expires_at: string | null; documents: RecoveryDocument | RecoveryDocument[] | null; }; @@ -91,7 +92,12 @@ function parseArgs(argv: string[]) { async function main() { const [ { env, requireServerEnv }, - { buildIngestionRecoveryPlan, reconcileIngestionRecoveryPlan, INGESTION_RECOVERY_JOB_STATUSES }, + { + buildIngestionRecoveryPlan, + reconcileIngestionRecoveryPlan, + applyIngestionRecoveryActions, + INGESTION_RECOVERY_JOB_STATUSES, + }, { createAdminClient }, { assertSupabaseHealthy, probeSupabaseHealth }, ] = await Promise.all([ @@ -116,7 +122,9 @@ async function main() { const openJobsQuery = () => { const query = supabase .from("ingestion_jobs") - .select("id,document_id,status,locked_at,documents!inner(status,page_count,chunk_count,owner_id)") + .select( + "id,document_id,status,locked_at,lease_token,lease_expires_at,documents!inner(status,page_count,chunk_count,owner_id)", + ) .in("status", [...INGESTION_RECOVERY_JOB_STATUSES]) .order("created_at", { ascending: true }); return args.ownerId ? query.eq("documents.owner_id", args.ownerId) : query; @@ -134,17 +142,15 @@ async function main() { const jobs = normaliseJobRows(data as RawJobRow[] | null); const plan = buildIngestionRecoveryPlan({ jobs, staleAfterMinutes }); const actions = plan.actions.slice(0, limit); - const resetDocumentIds = Array.from( - new Set(actions.filter((action) => action.action === "retry").map((action) => action.documentId)), - ); + const resetDocumentIds = plan.resetDocumentIds; const supersedeCount = actions.filter((action) => action.action === "supersede").length; const retryCount = actions.filter((action) => action.action === "retry").length; const remainingCount = Math.max(0, plan.actions.length - actions.length); - console.log(`Stale-after threshold : ${staleAfterMinutes} min`); + console.log(`Stale-after threshold : ${staleAfterMinutes} min (legacy rows only)`); console.log(`Action limit : ${limit}`); console.log(`Scanned jobs : ${jobs.length}`); - console.log(`Documents to reset : ${resetDocumentIds.length}`); + console.log(`Documents to reset : ${resetDocumentIds.length} (all artifacts preserved)`); console.log(`Jobs to supersede : ${supersedeCount}`); console.log(`Jobs to retry : ${retryCount}`); if (remainingCount > 0) { @@ -196,10 +202,8 @@ async function main() { if (actions.length > 0) { // The plan above is a point-in-time snapshot and the operator may have spent minutes reading it // at the confirmation prompt. In that window a job can complete, a worker can re-claim it, or a - // document can reach `indexed` — and the retry branch is destructive, because - // `reset_document_index` deletes every chunk, page, image and section for the document with no - // guard of its own. Re-derive the plan against current rows, then guard every write on the state - // just observed so a row that moved again is left alone rather than overwritten. (Audit R20/R21) + // document can reach `indexed`. Re-derive the plan, then let the atomic RPC validate + // current authority and lease identity before changing scheduling. (Audit R20/R21, F03) console.log("\nRe-reading the queue before applying..."); const { data: freshData, error: freshError } = await openJobsQuery(); if (freshError) throw supabaseStageError("re-read open ingestion jobs", freshError); @@ -222,183 +226,19 @@ async function main() { } else { console.log(`\nApplying job recovery (${applicable.length} of ${actions.length} planned action(s))...`); - // Recovery holds the job under its own worker id while the index is reset. `claim_ingestion_jobs` - // only claims `pending` rows or `processing` rows whose lock has gone stale, so a fresh lock keeps - // the worker off this document for the whole destructive window. - const recoveryWorkerId = `recovery:${randomUUID()}`; - - // Drops the recovery lock, guarded on this run owning it. Every post-claim exit goes through - // here: a job left `processing` under a fresh lock reads to the planner as an active job, so - // neither the worker nor the next recovery run would touch it until the lock aged past the - // stale threshold. - const releaseRecoveryClaim = async (jobId: string, stage: string, patch: Record) => { - const { error } = await supabase - .from("ingestion_jobs") - .update({ ...patch, stage, locked_by: null }) - .eq("id", jobId) - .eq("locked_by", recoveryWorkerId); - return error; - }; - const lostRace = (action: { action: string; jobId: string; documentId: string }, what: string) => - console.log(` skipped ${action.action} job ${action.jobId} (document ${action.documentId}): ${what}`); - + // One server transaction holds current document authority and both job-family locks. + // It rechecks the snapshot, preserves every index generation and requeues atomically. + // No client-side processing lease, destructive reset or compensating write is needed. let supersededCount = 0; let retriedCount = 0; - - // Supersedes first: a retry flips its row to an open status, which would collide with a still-open - // sibling on ingestion_jobs_one_open_per_document_uidx. (Audit I2/E2, preserved.) - for (const { action, expected } of applicable) { - if (action.action !== "supersede") continue; - if (!expected.jobStatus) { - lostRace(action, "job status was unreadable at re-read"); - continue; - } - - const supersede = supabase - .from("ingestion_jobs") - .update({ - status: "completed", - stage: "superseded by successful index", - progress: 100, - error_message: null, - locked_at: null, - locked_by: null, - completed_at: new Date().toISOString(), - }) - .eq("id", action.jobId) - .eq("status", expected.jobStatus); - const guarded = - expected.jobLockedAt === null - ? supersede.is("locked_at", null) - : supersede.eq("locked_at", expected.jobLockedAt); - - const { data: rows, error: supersedeError } = await guarded.select("id"); - if (supersedeError) throw supabaseStageError("supersede sibling ingestion job", supersedeError); - if ((rows ?? []).length === 0) { - lostRace(action, "job changed state before the write"); - continue; - } - supersededCount += 1; - } - - for (const { action, expected } of applicable) { - if (action.action !== "retry") continue; - if (!expected.jobStatus) { - lostRace(action, "job status was unreadable at re-read"); - continue; - } - - // Phase 1 — take the recovery lock, guarded on the exact row state observed at re-read. - const claim = supabase - .from("ingestion_jobs") - .update({ - status: "processing", - stage: "recovery: resetting index", - error_message: null, - locked_by: recoveryWorkerId, - locked_at: new Date().toISOString(), - }) - .eq("id", action.jobId) - .eq("status", expected.jobStatus); - const guardedClaim = - expected.jobLockedAt === null ? claim.is("locked_at", null) : claim.eq("locked_at", expected.jobLockedAt); - - const { data: claimed, error: claimError } = await guardedClaim.select("id"); - if (claimError) { - // 23505 = another open job for this document appeared after the re-read. Leave it to that job. - if ((claimError as { code?: string }).code === "23505") { - lostRace(action, "another open job for this document appeared before the write"); - continue; - } - throw supabaseStageError("claim ingestion job for recovery", claimError); - } - if ((claimed ?? []).length === 0) { - lostRace(action, "job changed state before the write"); + const results = await applyIngestionRecoveryActions(supabase, applicable); + for (const { action, applied, reason } of results) { + if (!applied) { + console.log(` skipped ${action.action} job ${action.jobId}: ${reason}`); continue; } - - // Everything from here is compensated on failure, because the recovery lock is now held. - let indexWasCleared = false; - try { - // Phase 2 — reset the document, guarded on the document status observed at re-read, so a - // document that reached `indexed` in the meantime keeps its committed index. - const documentUpdate = supabase - .from("documents") - .update({ status: "queued", error_message: null, page_count: 0, chunk_count: 0, image_count: 0 }) - .eq("id", action.documentId); - const scopedDocument = expected.documentOwnerId - ? documentUpdate.eq("owner_id", expected.documentOwnerId) - : documentUpdate.is("owner_id", null); - const guardedDocument = - expected.documentStatus === null - ? scopedDocument.is("status", null) - : scopedDocument.eq("status", expected.documentStatus); - - const { data: documentRows, error: documentError } = await guardedDocument.select("id"); - if (documentError) throw supabaseStageError("reset document status", documentError); - - if ((documentRows ?? []).length === 0) { - // The document moved on. Release the lock back to the state we found it in and touch nothing. - const releaseError = await releaseRecoveryClaim( - action.jobId, - "recovery: released, document changed state", - { - status: expected.jobStatus, - locked_at: expected.jobLockedAt, - }, - ); - if (releaseError) throw supabaseStageError("release recovery lock", releaseError); - lostRace(action, "document changed state before the write; index left intact"); - continue; - } - - // Past this point the document row is already zeroed, so a failure cannot be rolled back to - // the pre-recovery state — the compensation below carries it forward to a retryable one. - indexWasCleared = true; - - const { error: resetError } = await supabase.rpc("reset_document_index", { - p_document_id: action.documentId, - }); - if (resetError) throw supabaseStageError("reset document index", resetError); - - // Phase 3 — release the recovery lock back into the queue for the worker to claim. - const retryError = await releaseRecoveryClaim(action.jobId, "queued after recovery", { - status: "pending", - progress: 0, - attempt_count: 0, - error_message: null, - locked_at: null, - next_run_at: new Date().toISOString(), - completed_at: null, - }); - if (retryError) throw supabaseStageError("requeue ingestion job", retryError); - retriedCount += 1; - } catch (error) { - // Never rethrow while still holding the lock. If the document was not touched, put the job - // back exactly as it was found. If its index was already cleared, the document genuinely - // needs a rebuild now, so mark the job `failed` — which the planner treats as recoverable — - // rather than restoring a status that no longer describes the document. - const compensationError = indexWasCleared - ? await releaseRecoveryClaim(action.jobId, "recovery: interrupted after index reset", { - status: "failed", - locked_at: null, - error_message: - "Ingestion queue recovery was interrupted after the document index was cleared. Re-run recovery to rebuild.", - }) - : await releaseRecoveryClaim(action.jobId, "recovery: released after failure", { - status: expected.jobStatus, - locked_at: expected.jobLockedAt, - }); - - if (compensationError) { - // Report and keep going to the throw: the original failure is the more useful one, and a - // still-locked job ages out of the lock on its own after the stale threshold. - console.error( - ` failed to release the recovery lock on job ${action.jobId}; it will free itself once the lock goes stale.`, - ); - } - throw error; - } + if (action.action === "supersede") supersededCount += 1; + else retriedCount += 1; } console.log(`Ingestion queue recovery applied (superseded ${supersededCount}, requeued ${retriedCount}).`); diff --git a/scripts/reindex.ts b/scripts/reindex.ts index cfeb480062..018027e8eb 100644 --- a/scripts/reindex.ts +++ b/scripts/reindex.ts @@ -73,7 +73,13 @@ async function safeCount(label: string, countPromise: PromiseLike) async function main() { const [ { env, requireServerEnv }, - { buildIngestionRecoveryPlan, INGESTION_RECOVERY_JOB_STATUSES, isFreshProcessingJob }, + { + buildIngestionRecoveryPlan, + reconcileIngestionRecoveryPlan, + applyIngestionRecoveryActions, + INGESTION_RECOVERY_JOB_STATUSES, + isFreshProcessingJob, + }, { hasIncompleteDocumentsWithoutOpenJobs, isReindexQueueClear }, { createAdminClient }, { assertSupabaseHealthy, probeSupabaseHealth }, @@ -147,7 +153,9 @@ async function main() { .from("ingestion_jobs") .select("id", { count: "exact", head: true }) .eq("status", "processing") - .or(`locked_at.is.null,locked_at.lt.${staleCutoff}`), + .or( + `lease_expires_at.lte.${snapshotNow.toISOString()},and(lease_expires_at.is.null,or(locked_at.is.null,locked_at.lt.${staleCutoff}))`, + ), ), safeCount( "documents_queued", @@ -226,11 +234,15 @@ async function main() { if (needsRecovery) { console.log(`\n[Step 3] Queue recovery (stale: ${staleJobs.count}, failed: ${failedJobs.count})...`); - const { data, error } = await supabase - .from("ingestion_jobs") - .select("id,document_id,status,locked_at,documents(status,page_count,chunk_count)") - .in("status", [...INGESTION_RECOVERY_JOB_STATUSES]) - .order("created_at", { ascending: true }); + const recoveryJobsQuery = () => + supabase + .from("ingestion_jobs") + .select( + "id,document_id,status,locked_at,lease_token,lease_expires_at,documents(status,page_count,chunk_count,owner_id)", + ) + .in("status", [...INGESTION_RECOVERY_JOB_STATUSES]) + .order("created_at", { ascending: true }); + const { data, error } = await recoveryJobsQuery(); if (error) { console.error(` Failed to load jobs for recovery: ${error.message}`); @@ -238,12 +250,19 @@ async function main() { return; } - type RecoveryDocument = { status?: string | null; page_count?: number | null; chunk_count?: number | null }; + type RecoveryDocument = { + status?: string | null; + page_count?: number | null; + chunk_count?: number | null; + owner_id?: string | null; + }; type RawJobRow = { id: string; document_id: string; status: string | null; locked_at: string | null; + lease_token: string | null; + lease_expires_at: string | null; documents: RecoveryDocument | RecoveryDocument[] | null; }; // Pass every queried job (pending/processing/failed) to the planner and let its @@ -267,13 +286,11 @@ async function main() { .some((job) => isFreshProcessingJob(job, snapshotNow, staleAfterMinutes)); const plan = buildIngestionRecoveryPlan({ jobs, staleAfterMinutes }); const actions = plan.actions.slice(0, limit); - const resetDocumentIds = Array.from( - new Set(actions.filter((a) => a.action === "retry").map((a) => a.documentId)), - ); + const resetDocumentIds = plan.resetDocumentIds; const supersedeCount = actions.filter((a) => a.action === "supersede").length; const retryCount = actions.filter((a) => a.action === "retry").length; - console.log(` Documents to reset: ${resetDocumentIds.length}`); + console.log(` Documents to reset: ${resetDocumentIds.length} (all artifacts preserved)`); console.log(` Jobs to supersede : ${supersedeCount}`); console.log(` Jobs to retry : ${retryCount}`); @@ -291,68 +308,23 @@ async function main() { console.log(" Recovery skipped. Re-checking queue before worker run."); skipWorkerRun = true; } else { - for (const documentId of resetDocumentIds) { - const { error: resetError } = await supabase.rpc("reset_document_index", { - p_document_id: documentId, - }); - if (resetError) { - console.error(` Failed to reset document index ${documentId}: ${resetError.message}`); - process.exitCode = 1; - return; - } - const { error: docError } = await supabase - .from("documents") - .update({ status: "queued", error_message: null, page_count: 0, chunk_count: 0, image_count: 0 }) - .eq("id", documentId); - if (docError) { - console.error(` Failed to reset document status ${documentId}: ${docError.message}`); - process.exitCode = 1; - return; - } - } - - for (const action of actions) { - if (action.action === "supersede") { - const { error: supersedeError } = await supabase - .from("ingestion_jobs") - .update({ - status: "completed", - stage: "superseded by successful index", - progress: 100, - error_message: null, - locked_at: null, - locked_by: null, - completed_at: new Date().toISOString(), - }) - .eq("id", action.jobId); - if (supersedeError) { - console.error(` Failed to supersede job ${action.jobId}: ${supersedeError.message}`); - process.exitCode = 1; - return; - } - continue; - } - - const { error: retryError } = await supabase - .from("ingestion_jobs") - .update({ - status: "pending", - stage: "queued after recovery", - progress: 0, - attempt_count: 0, - error_message: null, - locked_at: null, - locked_by: null, - next_run_at: new Date().toISOString(), - completed_at: null, - }) - .eq("id", action.jobId); - if (retryError) { - console.error(` Failed to requeue job ${action.jobId}: ${retryError.message}`); - process.exitCode = 1; - return; - } + const { data: freshData, error: freshError } = await recoveryJobsQuery(); + if (freshError) throw new Error(freshError.message); + const freshJobs = (freshData ?? []).map((job: RawJobRow) => ({ + ...job, + documents: Array.isArray(job.documents) ? job.documents[0] : job.documents, + })); + const { applicable, skipped } = reconcileIngestionRecoveryPlan({ + planned: actions, + jobs: freshJobs, + staleAfterMinutes, + }); + const results = await applyIngestionRecoveryActions(supabase, applicable); + for (const { action, applied, reason } of results) { + if (!applied) console.log(` Skipped ${action.action} job ${action.jobId}: ${reason}`); } + // Re-snapshot before launching another worker when authority moved or a managed hold refused. + if (skipped.length > 0 || results.some((result) => !result.applied)) skipWorkerRun = true; console.log(" Recovery applied."); } diff --git a/scripts/test-run-lock.mjs b/scripts/test-run-lock.mjs index f72d10c0b0..0839ff9886 100644 --- a/scripts/test-run-lock.mjs +++ b/scripts/test-run-lock.mjs @@ -37,14 +37,29 @@ function defaultWaitTimeoutFor(mode) { return mode === "exclusive" ? defaultExclusiveWaitTimeoutMs : defaultSharedWaitTimeoutMs; } +function linuxProcessIsDefunct(status) { + // A zombie group leader can still have live worker threads after pthread_exit. + // Reclaim only a known terminal state with a positively identified sole thread. + return /^State:[ \t]+[ZX]\b/m.test(status) && /^Threads:[ \t]+1[ \t]*$/m.test(status); +} + function processIsAlive(pid) { if (!Number.isInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); - return true; } catch (error) { - return error?.code === "EPERM"; + if (error?.code !== "EPERM") return false; + } + if (process.platform === "linux") { + try { + // kill(pid, 0) also succeeds for unreaped zombies. Require that no worker + // threads remain before normal stale-owner pruning can reclaim the lease. + if (linuxProcessIsDefunct(readFileSync(`/proc/${pid}/status`, "utf8"))) return false; + } catch { + // Unavailable procfs must not weaken the existing kill/EPERM boundary. + } } + return true; } function sleepSync(milliseconds) { @@ -512,6 +527,7 @@ export const testRunLockInternals = { coordinatorPaths, defaultWaitTimeoutFor, isCoordinator, + linuxProcessIsDefunct, lockPathFor, processIsAlive, readOwner, diff --git a/scripts/worker-python-lock-config.mjs b/scripts/worker-python-lock-config.mjs index 768263ee01..bdb6493245 100644 --- a/scripts/worker-python-lock-config.mjs +++ b/scripts/worker-python-lock-config.mjs @@ -5,6 +5,7 @@ export const workerPythonLockTargets = Object.freeze({ production: Object.freeze({ name: "production", pythonVersion: "3.11", + inputFile: "worker/python/requirements.in", outputFile: "worker/python/requirements.txt", generateCommand: "npm run generate:worker-python-lock", includeUnsafe: false, @@ -12,6 +13,7 @@ export const workerPythonLockTargets = Object.freeze({ cloud: Object.freeze({ name: "cloud", pythonVersion: "3.12", + inputFile: "worker/python/requirements-cloud.in", outputFile: "worker/python/requirements-cloud.txt", generateCommand: "npm run generate:worker-python-cloud-lock", includeUnsafe: true, diff --git a/src/app/api/documents/[id]/reviews/route.ts b/src/app/api/documents/[id]/reviews/route.ts index 004ecddcc5..52a9556ad1 100644 --- a/src/app/api/documents/[id]/reviews/route.ts +++ b/src/app/api/documents/[id]/reviews/route.ts @@ -209,6 +209,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: // Public documents participate in every owner's retrieval view, so a // public-corpus governance change must clear all local and shared scopes. + // These clears reclaim entries; the fresh database scope revision fences + // every cache hit even on another replica or when shared deletion fails. if (document.owner_id === null) invalidateRagCachesForOwner(); else invalidateRagCachesForOwner(user.id); return NextResponse.json({ review: data }, { status: 201 }); diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 42eb160fcb..f701038150 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -10,6 +10,7 @@ import { PublicApiError, } from "@/lib/http"; import { logger } from "@/lib/logger"; +import { contentFreeErrorDetails } from "@/lib/privacy"; import { writeAuditLog } from "@/lib/audit"; import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { planDocumentName, type DocumentNamePlan, type DocumentNameSupabase } from "@/lib/document-naming"; @@ -132,8 +133,8 @@ async function duplicateUploadResponse(args: { .remove([args.storagePath]); if (cleanupStorageError) { logger.warn("Duplicate upload storage cleanup failed", { - storagePath: args.storagePath, - message: cleanupStorageError.message, + storageObjectRef: createHash("sha256").update(args.storagePath).digest("hex"), + ...contentFreeErrorDetails(cleanupStorageError), }); const { error: cleanupLedgerError } = await args.supabase.from("storage_cleanup_jobs").insert({ document_bucket: env.SUPABASE_DOCUMENT_BUCKET, @@ -146,7 +147,7 @@ async function duplicateUploadResponse(args: { if (cleanupLedgerError) { // Keep the duplicate response path fail-closed for orphaned objects: without a // ledger row there is no durable recovery record after storage.remove() failed. - throw new Error(`Duplicate upload cleanup ledger insert failed: ${cleanupLedgerError.message}`); + throw new Error("Duplicate upload cleanup ledger insert failed."); } } } @@ -203,14 +204,13 @@ async function releaseSupersededUpload(args: { documentId: args.documentId, supersededPath, committedPath: args.committedPath, - }).catch((error: unknown) => ({ + }).catch(() => ({ outcome: "unrecorded" as const, - failures: [error instanceof Error ? error.message : "release failed"], + failures: [{ stage: "unknown" as const, code: "unknown" as const }], })); if (release.outcome === "unrecorded") { logger.error("Superseded upload object cleanup failed; object requires manual reconciliation", { - storagePath: supersededPath, - ownerId: args.ownerId, + storageObjectRef: createHash("sha256").update(supersededPath).digest("hex"), failures: release.failures, }); } @@ -610,7 +610,7 @@ export async function POST(request: Request) { image_bucket: env.SUPABASE_IMAGE_BUCKET, image_paths: [], }); - if (ledgerError) throw new Error(`Upload cleanup ledger insert failed: ${ledgerError.message}`); + if (ledgerError) throw new Error("Upload cleanup ledger insert failed."); } uploadedPath = null; durableAdmission = true; @@ -622,7 +622,12 @@ export async function POST(request: Request) { // The duplicate response helper exclusively owns exact-path compensation // and its durable cleanup-ledger fallback. preserveUploadedArtifact = true; - return duplicateUploadResponse({ supabase, ownerId: uploadOwnerId, contentHash, storagePath: uploadedPath }); + return await duplicateUploadResponse({ + supabase, + ownerId: uploadOwnerId, + contentHash, + storagePath: uploadedPath, + }); } throw dispatchError; } @@ -655,8 +660,8 @@ export async function POST(request: Request) { .remove([uploadedPath]); if (cleanupStorageError) { logger.error("Upload cleanup failed; storage object may be orphaned", { - storagePath: uploadedPath, - message: cleanupStorageError.message, + storageObjectRef: createHash("sha256").update(uploadedPath).digest("hex"), + ...contentFreeErrorDetails(cleanupStorageError), }); const { error: cleanupLedgerError } = await supabase.from("storage_cleanup_jobs").insert({ document_bucket: env.SUPABASE_DOCUMENT_BUCKET, @@ -668,18 +673,18 @@ export async function POST(request: Request) { }); if (cleanupLedgerError) { logger.error("Upload cleanup ledger insert failed; orphaned object requires manual reconciliation", { - storagePath: uploadedPath, - ownerId: cleanupOwnerId, - message: cleanupLedgerError.message, + storageObjectRef: createHash("sha256").update(uploadedPath).digest("hex"), + ...contentFreeErrorDetails(cleanupLedgerError), }); } } } catch (cleanupError) { // Cleanup is best-effort, but a silent failure leaves an orphaned storage - // object. Record the path so it can be reconciled instead of dropping it. + // object. Keep its bytes on the scoped cleanup queue; logs contain only + // an opaque object digest and fixed error classifications. logger.error("Upload cleanup failed; storage object may be orphaned", { - storagePath: uploadedPath, - message: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + storageObjectRef: createHash("sha256").update(uploadedPath).digest("hex"), + ...contentFreeErrorDetails(cleanupError), }); try { const { error: cleanupLedgerError } = await supabase.from("storage_cleanup_jobs").insert({ @@ -692,16 +697,14 @@ export async function POST(request: Request) { }); if (cleanupLedgerError) { logger.error("Upload cleanup ledger insert failed; orphaned object requires manual reconciliation", { - storagePath: uploadedPath, - ownerId: cleanupOwnerId, - message: cleanupLedgerError.message, + storageObjectRef: createHash("sha256").update(uploadedPath).digest("hex"), + ...contentFreeErrorDetails(cleanupLedgerError), }); } } catch (cleanupLedgerError) { logger.error("Upload cleanup ledger insert failed; orphaned object requires manual reconciliation", { - storagePath: uploadedPath, - ownerId: cleanupOwnerId, - message: cleanupLedgerError instanceof Error ? cleanupLedgerError.message : String(cleanupLedgerError), + storageObjectRef: createHash("sha256").update(uploadedPath).digest("hex"), + ...contentFreeErrorDetails(cleanupLedgerError), }); } } @@ -720,7 +723,8 @@ export async function POST(request: Request) { }); } - return jsonError(error); + logger.error("Upload request failed", contentFreeErrorDetails(error)); + return jsonError(error, 500, { log: false }); } finally { releaseAdmission?.(); } diff --git a/src/app/api/work/hospital/sick/route.ts b/src/app/api/work/hospital/sick/route.ts index 835c58d600..5b7fcc2744 100644 --- a/src/app/api/work/hospital/sick/route.ts +++ b/src/app/api/work/hospital/sick/route.ts @@ -7,6 +7,7 @@ import { } from "@/lib/api-rate-limit"; import { isDemoMode } from "@/lib/env"; import { jsonError, publicErrorResponse } from "@/lib/http"; +import { rosterTeamReleaseEnabled } from "@/lib/roster/team/release"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; import { readHospitalSickCalls } from "@/lib/work-roles/hospital-sick"; @@ -23,6 +24,12 @@ export async function GET(request: Request) { if (isDemoMode()) { return publicErrorResponse("Roles can't be kept in PsychSift yet.", 503, { code: "work_roles_not_ready" }); } + // This named projection comes from team roster reports and shares the real-staff hold. + if (!rosterTeamReleaseEnabled()) { + return publicErrorResponse("Sick calls aren't available for real staff yet.", 503, { + code: "work_roles_not_ready", + }); + } const hospitalId = new URL(request.url).searchParams.get("hospitalId") ?? ""; if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(hospitalId)) { return publicErrorResponse("Choose a hospital.", 400, { code: "work_people_invalid" }); diff --git a/src/components/account-data-provider.tsx b/src/components/account-data-provider.tsx index 0cdfb42750..06643b2921 100644 --- a/src/components/account-data-provider.tsx +++ b/src/components/account-data-provider.tsx @@ -1,6 +1,16 @@ "use client"; -import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { useAuthSession } from "@/lib/supabase/client"; import { parseApiErrorResponse } from "@/lib/api-client-error"; @@ -107,6 +117,15 @@ function favouritesByType(rows: AccountFavourite[]): FavouritesByType { export function AccountDataProvider({ children }: { children: ReactNode }) { const auth = useAuthSession(); + const scope = `${auth.authEpoch}:${auth.status}:${auth.session?.user.id ?? ""}`; + const epoch = auth.authEpoch; + const isAuthEpochCurrent = auth.isAuthEpochCurrent; + const currentScope = useRef(scope); + const [owner, setOwner] = useState(scope); + const isCurrent = useCallback( + () => currentScope.current === scope && (isAuthEpochCurrent?.(epoch) ?? true), + [epoch, isAuthEpochCurrent, scope], + ); const [favourites, setFavourites] = useState(emptyFavourites); const [favouriteItems, setFavouriteItems] = useState([]); const [favouriteSets, setFavouriteSets] = useState([]); @@ -123,7 +142,33 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { // Bumping this re-runs the load effect unchanged, so an explicit Retry gets // exactly the same clearing and abort semantics as an auth transition. const [reloadAttempt, setReloadAttempt] = useState(0); - const reload = useCallback(() => setReloadAttempt((attempt) => attempt + 1), []); + const reload = useCallback(() => { + if (isCurrent()) setReloadAttempt((attempt) => attempt + 1); + }, [isCurrent]); + if (owner !== scope) { + setOwner(scope); + setFavourites(emptyFavourites); + setFavouriteItems([]); + setFavouriteSets([]); + setReady(auth.status !== "authenticated"); + setLoadError(null); + setError(null); + } + // Invalidate retained callbacks and detach old queues before any passive read + // or user interaction can run for the newly committed identity. + useLayoutEffect(() => { + currentScope.current = scope; + favouritesRef.current = emptyFavourites; + favouriteItemsRef.current = []; + favouriteSetsRef.current = []; + favouriteMutationsRef.current = new Map(); + favouriteClearTailRef.current = Promise.resolve(); + structuredMutationTailRef.current = Promise.resolve(); + structuredMutationVersionRef.current = 0; + return () => { + currentScope.current = null; + }; + }, [scope]); const replaceFavourites = useCallback((next: FavouritesByType) => { favouritesRef.current = next; setFavourites(next); @@ -182,7 +227,7 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { const refreshDemoFavourites = () => replaceFavourites(demoAccountData ? readDemoFavourites() : emptyFavourites); let cancelled = false; queueMicrotask(() => { - if (cancelled) return; + if (cancelled || !isCurrent()) return; refreshDemoFavourites(); replaceFavouriteItems([]); replaceFavouriteSets([]); @@ -199,7 +244,7 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { const controller = new AbortController(); queueMicrotask(() => { - if (!controller.signal.aborted) setReady(false); + if (!controller.signal.aborted && isCurrent()) setReady(false); }); fetch("/api/account/favourites", { cache: "no-store", @@ -207,6 +252,7 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { signal: controller.signal, }) .then(async (response) => { + if (controller.signal.aborted || !isCurrent()) return; // A rejected token cannot be recovered by re-sending it, so an expired // session has to change the auth state rather than only the error text. // Retry now exists on this path; without this it would reissue the same @@ -215,6 +261,7 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { if (response.status === 401) markSessionExpired(); if (!response.ok) throw await parseApiErrorResponse(response); const payload: unknown = await response.json().catch(() => null); + if (controller.signal.aborted || !isCurrent()) return; const snapshot = parseFavouritesSnapshot(payload); if (!snapshot) throw new Error("Saved items returned an invalid response."); replaceFavourites(favouritesByType(snapshot.favourites)); @@ -224,6 +271,7 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { setError(null); }) .catch((cause) => { + if (controller.signal.aborted || !isCurrent()) return; if (cause instanceof DOMException && cause.name === "AbortError") return; replaceFavourites(emptyFavourites); replaceFavouriteItems([]); @@ -232,11 +280,12 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { setError(null); }) .finally(() => { - if (!controller.signal.aborted) setReady(true); + if (!controller.signal.aborted && isCurrent()) setReady(true); }); return () => controller.abort(); }, [ + isCurrent, auth.authEpoch, authorizationHeader, authStatus, @@ -249,6 +298,7 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { const setFavourite = useCallback( async (contentType: FavouriteContentType, contentKey: string, saved: boolean) => { + if (!isCurrent()) return false; if (authStatus !== "authenticated") { if (demoAccountData) { const current = favouritesRef.current[contentType]; @@ -288,6 +338,7 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { // clear-all barrier. A write that begins after Clear therefore cannot race // ahead of its DELETE and recreate a favourite out of order. const request = Promise.all([mutation.tail, favouriteClearTailRef.current]).then(async () => { + if (!isCurrent()) return false; const response = await fetch("/api/account/favourites", { method: "PUT", headers: { "Content-Type": "application/json", ...authorizationHeader }, @@ -299,14 +350,20 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { saved, }), }).catch(() => null); + if (!isCurrent()) return false; if (!response?.ok) { // Mutation failures must not poison loadError: the library already loaded, // and Retry-on-GET would mis-describe a failed write as an unread library. - setError(response ? (await parseApiErrorResponse(response)).message : "Saved items could not be updated."); + const message = response + ? (await parseApiErrorResponse(response)).message + : "Saved items could not be updated."; + if (!isCurrent()) return false; + setError(message); if (response?.status === 401) markSessionExpired(); return false; } const payload = await response.json().catch(() => null); + if (!isCurrent()) return false; if (!isFavouriteMembershipResponse(payload)) { setError("Saved-item response was invalid."); return false; @@ -323,6 +380,7 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { ); const succeeded = await request; + if (!isCurrent()) return false; mutation.pending -= 1; if (mutation.pending === 0) { applyFavourite(contentType, key, mutation.confirmed); @@ -338,27 +396,33 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { } return succeeded; }, - [applyFavourite, authStatus, authorizationHeader, markSessionExpired, replaceFavouriteItems], + [applyFavourite, authStatus, authorizationHeader, isCurrent, markSessionExpired, replaceFavouriteItems], ); const runStructuredMutation = useCallback( async (method: "POST" | "PATCH", body: Record, failureMessage: string) => { + if (!isCurrent()) return null; if (authStatus !== "authenticated") { setError("Sign in or create an account to organise favourites."); return null; } const request = Promise.all([structuredMutationTailRef.current, favouriteClearTailRef.current]).then(async () => { + if (!isCurrent()) return null; const response = await fetch("/api/account/favourites", { method, headers: { "Content-Type": "application/json", ...authorizationHeader }, body: JSON.stringify({ version: favouritesContractVersion, ...body }), }).catch(() => null); + if (!isCurrent()) return null; if (!response?.ok) { - setError(response ? (await parseApiErrorResponse(response)).message : failureMessage); + const message = response ? (await parseApiErrorResponse(response)).message : failureMessage; + if (!isCurrent()) return null; + setError(message); if (response?.status === 401) markSessionExpired(); return null; } const payload = await response.json().catch(() => null); + if (!isCurrent()) return null; setError(null); return payload as unknown; }); @@ -368,11 +432,12 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { ); return request; }, - [authStatus, authorizationHeader, markSessionExpired], + [authStatus, authorizationHeader, isCurrent, markSessionExpired], ); const createFavouriteSet = useCallback( async (name: FavouriteSetName) => { + if (!isCurrent()) return null; const version = ++structuredMutationVersionRef.current; const previous = favouriteSetsRef.current; const now = new Date().toISOString(); @@ -389,6 +454,7 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { { action: "createSet", name }, "Favourite set could not be created.", ); + if (!isCurrent()) return null; if (!payload) { if (structuredMutationVersionRef.current === version) replaceFavouriteSets(previous); else void structuredMutationTailRef.current.then(reload); @@ -403,11 +469,12 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { replaceFavouriteSets(favouriteSetsRef.current.map((set) => (set.id === temporary.id ? created : set))); return created; }, - [reload, replaceFavouriteSets, runStructuredMutation], + [isCurrent, reload, replaceFavouriteSets, runStructuredMutation], ); const renameFavouriteSet = useCallback( async (setId: string, name: FavouriteSetName) => { + if (!isCurrent()) return null; const version = ++structuredMutationVersionRef.current; const previous = favouriteSetsRef.current; replaceFavouriteSets(previous.map((set) => (set.id === setId ? { ...set, name } : set))); @@ -416,6 +483,7 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { { action: "renameSet", setId, name }, "Favourite set could not be renamed.", ); + if (!isCurrent()) return null; const renamed = payload ? parseFavouriteSetResponse(payload) : null; if (renamed) { replaceFavouriteSets(favouriteSetsRef.current.map((set) => (set.id === setId ? renamed : set))); @@ -426,11 +494,12 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { else void structuredMutationTailRef.current.then(reload); return null; }, - [reload, replaceFavouriteSets, runStructuredMutation], + [isCurrent, reload, replaceFavouriteSets, runStructuredMutation], ); const deleteFavouriteSet = useCallback( async (setId: string) => { + if (!isCurrent()) return false; const version = ++structuredMutationVersionRef.current; const previousSets = favouriteSetsRef.current; const previousItems = favouriteItemsRef.current; @@ -441,6 +510,7 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { { action: "deleteSet", setId }, "Favourite set could not be deleted.", ); + if (!isCurrent()) return false; if (isFavouriteUpdateResponse(payload)) return true; if (payload) setError("Favourite set response was invalid."); if (structuredMutationVersionRef.current === version) { @@ -449,11 +519,12 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { } else void structuredMutationTailRef.current.then(reload); return false; }, - [reload, replaceFavouriteItems, replaceFavouriteSets, runStructuredMutation], + [isCurrent, reload, replaceFavouriteItems, replaceFavouriteSets, runStructuredMutation], ); const setFavouriteOrder = useCallback( async (setId: string | null, items: Array<{ contentType: FavouriteContentType; contentKey: string }>) => { + if (!isCurrent()) return false; const version = ++structuredMutationVersionRef.current; const previous = favouriteItemsRef.current; const position = new Map(items.map((item, index) => [`${item.contentType}:${item.contentKey}`, index])); @@ -468,6 +539,7 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { { action: "setItemOrder", setId, items }, "Favourite order could not be updated.", ); + if (!isCurrent()) return false; if (isFavouriteUpdateResponse(payload)) return true; if (payload) setError("Favourite order response was invalid."); if (structuredMutationVersionRef.current === version) replaceFavouriteItems(previous); @@ -475,11 +547,12 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { void structuredMutationTailRef.current.then(reload); return false; }, - [reload, replaceFavouriteItems, runStructuredMutation], + [isCurrent, reload, replaceFavouriteItems, runStructuredMutation], ); const moveFavourite = useCallback( async (contentType: FavouriteContentType, contentKey: string, setId: string | null) => { + if (!isCurrent()) return false; const version = ++structuredMutationVersionRef.current; const previous = favouriteItemsRef.current; replaceFavouriteItems( @@ -492,22 +565,25 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { { action: "moveItem", contentType, contentKey, setId }, "Favourite could not be moved.", ); + if (!isCurrent()) return false; if (isFavouriteUpdateResponse(payload)) return true; if (payload) setError("Favourite move response was invalid."); if (structuredMutationVersionRef.current === version) replaceFavouriteItems(previous); else void structuredMutationTailRef.current.then(reload); return false; }, - [reload, replaceFavouriteItems, runStructuredMutation], + [isCurrent, reload, replaceFavouriteItems, runStructuredMutation], ); const reorderFavourite = useCallback( async (contentType: FavouriteContentType, contentKey: string, direction: "up" | "down") => { + if (!isCurrent()) return false; const payload = await runStructuredMutation( "PATCH", { action: "reorderItem", contentType, contentKey, direction }, "Favourite order could not be updated.", ); + if (!isCurrent()) return false; if (isFavouriteUpdateResponse(payload)) { reload(); return true; @@ -515,11 +591,12 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { if (payload) setError("Favourite order response was invalid."); return false; }, - [reload, runStructuredMutation], + [isCurrent, reload, runStructuredMutation], ); const setFavouritePinned = useCallback( async (contentType: FavouriteContentType, contentKey: string, pinned: boolean) => { + if (!isCurrent()) return false; const version = ++structuredMutationVersionRef.current; const previous = favouriteItemsRef.current; const pinnedAt = pinned ? new Date().toISOString() : null; @@ -533,17 +610,19 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { { action: "setPinned", contentType, contentKey, pinned }, pinned ? "Favourite could not be added to quick launch." : "Favourite could not be removed from quick launch.", ); + if (!isCurrent()) return false; if (isFavouriteUpdateResponse(payload)) return true; if (payload) setError("Favourite pin response was invalid."); if (structuredMutationVersionRef.current === version) replaceFavouriteItems(previous); else void structuredMutationTailRef.current.then(reload); return false; }, - [reload, replaceFavouriteItems, runStructuredMutation], + [isCurrent, reload, replaceFavouriteItems, runStructuredMutation], ); const recordFavouriteOpen = useCallback( async (contentType: FavouriteContentType, contentKey: string) => { + if (!isCurrent()) return false; const version = ++structuredMutationVersionRef.current; const previous = favouriteItemsRef.current; const existing = previous.find((item) => item.contentType === contentType && item.contentKey === contentKey); @@ -567,6 +646,7 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { { action: "recordOpen", contentType, contentKey }, "Favourite activity could not be recorded.", ); + if (!isCurrent()) return false; if (isFavouriteUpdateResponse(payload)) return true; if (payload) setError("Favourite activity response was invalid."); if (shouldUpdateState) { @@ -575,10 +655,11 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { } return false; }, - [reload, replaceFavouriteItems, runStructuredMutation], + [isCurrent, reload, replaceFavouriteItems, runStructuredMutation], ); const clearFavourites = useCallback(async () => { + if (!isCurrent()) return false; if (authStatus !== "authenticated") { if (!demoAccountData) return false; return (Object.values(storageKeyByType) as string[]).every((key) => writeSavedRegistrySlugs(key, [])); @@ -592,16 +673,19 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { structuredMutationTailRef.current, ...pendingMutationTails, ]).then(async () => { + if (!isCurrent()) return false; const response = await fetch("/api/account/favourites", { method: "DELETE", headers: authorizationHeader, }).catch(() => null); + if (!isCurrent()) return false; if (!response?.ok) { setError("Saved items could not be cleared."); if (response?.status === 401) markSessionExpired(); return false; } const payload = await response.json().catch(() => null); + if (!isCurrent()) return false; if (!isFavouritesClearResponse(payload)) { setError("Saved-item clear response was invalid."); return false; @@ -623,7 +707,7 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { () => undefined, ); return request; - }, [authStatus, authorizationHeader, markSessionExpired, replaceFavouriteItems, replaceFavourites]); + }, [authStatus, authorizationHeader, isCurrent, markSessionExpired, replaceFavouriteItems, replaceFavourites]); const isSaved = useCallback( (contentType: FavouriteContentType, contentKey: string) => favourites[contentType].includes(contentKey), diff --git a/src/components/admin/admin-new-job-page.tsx b/src/components/admin/admin-new-job-page.tsx index 13d8e9e623..f917b28960 100644 --- a/src/components/admin/admin-new-job-page.tsx +++ b/src/components/admin/admin-new-job-page.tsx @@ -4,6 +4,7 @@ import dynamic from "next/dynamic"; import { ClipboardList, Copy, FileText } from "lucide-react"; import { useMemo, useRef, useState } from "react"; +import { useAccountRequestScope } from "@/components/use-account-request-scope"; import { useAccountData } from "@/components/account-data-provider"; import { AdminCallButton, @@ -201,6 +202,8 @@ function PaperworkSignpost({ overview }: { readonly overview: ComplianceOverview * decision) — there is no week strip. */ export function AdminNewJobPage({ now: nowProp }: { now?: Date } = {}) { + const { scope, isCurrent } = useAccountRequestScope(); + const [owner, setOwner] = useState(scope); const { isAuthenticated } = useAccountData(); const state = useOnCallEntries(); const { entries, retry } = state; @@ -218,6 +221,15 @@ export function AdminNewJobPage({ now: nowProp }: { now?: Date } = {}) { const [toggleError, setToggleError] = useState(null); const [signInOpen, setSignInOpen] = useState(false); + if (owner !== scope) { + setOwner(scope); + setEditorState({ open: false, entry: null, section: "logistics" }); + setEditorMounted(false); + setUndo(null); + setToggleError(null); + setSignInOpen(false); + } + const own = useMemo(() => selectAdminOwnEntries(state), [state]); const shared = useMemo(() => selectAdminSharedEntries(state), [state]); const loadState = adminLoadState(state); @@ -248,6 +260,7 @@ export function AdminNewJobPage({ now: nowProp }: { now?: Date } = {}) { : null; function upsertCachedEntry(entry: OnCallEntry) { + if (!isCurrent()) return; if (isOnCallExampleEntry(entry)) return; const next = entries.some((existing) => existing.id === entry.id) ? entries.map((existing) => (existing.id === entry.id ? entry : existing)) @@ -256,57 +269,67 @@ export function AdminNewJobPage({ now: nowProp }: { now?: Date } = {}) { } function removeCachedEntry(id: string) { + if (!isCurrent()) return; cacheOnCallEntries(entries.filter((existing) => existing.id !== id)); } async function handleToggle(entry: OnCallEntry, done: boolean) { + if (!isCurrent()) return; setToggleError(null); const update = setNewJobStepDone(entry, done); try { const saved = await patchEntry(entry.id, update.body); + if (!isCurrent()) return; upsertCachedEntry(saved); setUndo({ id: ++nextUndoId.current, entryId: entry.id, restore: update.undo, label: "Saved" }); } catch (error) { + if (!isCurrent()) return; setToggleError(error instanceof Error ? error.message : "Could not save this change."); } } async function handleUndo() { - if (!undo) return; + if (!isCurrent() || !undo) return; const { entryId, restore } = undo; setUndo(null); try { const saved = await patchEntry(entryId, restore); + if (!isCurrent()) return; upsertCachedEntry(saved); } catch (error) { + if (!isCurrent()) return; setToggleError(error instanceof Error ? error.message : "Could not undo that change."); } } async function handleSetStart(date: string): Promise { - if (!startEntry) return false; + if (!isCurrent() || !startEntry) return false; const result = setNewJobStart(startEntry, date); if (!result.ok) return false; try { const saved = await patchEntry(startEntry.id, result.body); + if (!isCurrent()) return false; upsertCachedEntry(saved); setUndo({ id: ++nextUndoId.current, entryId: startEntry.id, restore: result.undo, label: "Saved" }); return true; } catch (error) { + if (!isCurrent()) return false; setToggleError(error instanceof Error ? error.message : "Could not save the start date."); return false; } } async function handleClearStart() { - if (!startEntry) return; + if (!isCurrent() || !startEntry) return; const result = setNewJobStart(startEntry, null); if (!result.ok) return; try { const saved = await patchEntry(startEntry.id, result.body); + if (!isCurrent()) return; upsertCachedEntry(saved); setUndo({ id: ++nextUndoId.current, entryId: startEntry.id, restore: result.undo, label: "Saved" }); } catch (error) { + if (!isCurrent()) return; setToggleError(error instanceof Error ? error.message : "Could not clear the start date."); } } diff --git a/src/components/admin/junior/use-roster-leave.ts b/src/components/admin/junior/use-roster-leave.ts index d145fac359..fdf8e83637 100644 --- a/src/components/admin/junior/use-roster-leave.ts +++ b/src/components/admin/junior/use-roster-leave.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; +import { useAccountRequestScope } from "@/components/use-account-request-scope"; import { isRosterLeaveKind, ROSTER_LEAVE_KIND_LABEL, type RosterLeaveKind } from "@/lib/roster/leave-kinds"; /** The doctor's own planned, applied and approved leave from Roster, read once into memory. */ @@ -48,7 +49,8 @@ function parseLeave(value: unknown): JuniorRosterLeave[] { * `reload` reads it again (a page's Retry). */ export function useJuniorRosterLeave(enabled: boolean, reload = 0): JuniorRosterLeaveState { - const [state, setState] = useState({ status: "loading" }); + const { scope, isCurrent } = useAccountRequestScope(); + const [state, setState] = useState<{ scope: string; value: JuniorRosterLeaveState } | null>(null); useEffect(() => { if (!enabled) return; const controller = new AbortController(); @@ -56,14 +58,15 @@ export function useJuniorRosterLeave(enabled: boolean, reload = 0): JuniorRoster .then(async (response) => { if (!response.ok) throw new Error("leave"); const payload = (await response.json()) as { leave?: unknown }; - if (!controller.signal.aborted) setState({ status: "ready", leave: parseLeave(payload?.leave) }); + if (!controller.signal.aborted && isCurrent()) + setState({ scope, value: { status: "ready", leave: parseLeave(payload?.leave) } }); }) .catch(() => { - if (!controller.signal.aborted) setState({ status: "failed" }); + if (!controller.signal.aborted && isCurrent()) setState({ scope, value: { status: "failed" } }); }); return () => controller.abort(); - }, [enabled, reload]); - return state; + }, [enabled, reload, scope, isCurrent]); + return state?.scope === scope ? state.value : { status: "loading" }; } export const ROSTER_LEAVE_STATUS_WORDS: Record = { diff --git a/src/components/admin/new-job/admin-new-job-sharing.tsx b/src/components/admin/new-job/admin-new-job-sharing.tsx index 42bfa1af1e..ea1ece4da5 100644 --- a/src/components/admin/new-job/admin-new-job-sharing.tsx +++ b/src/components/admin/new-job/admin-new-job-sharing.tsx @@ -3,6 +3,7 @@ import { RotateCcw } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; +import { useAccountRequestScope } from "@/components/use-account-request-scope"; import { AdminSection, AdminSkeleton, adminStyles } from "@/components/admin/admin-kit"; import { focusRing } from "@/components/card-recipes"; import { WorkButton, WorkCard } from "@/components/mode-kit/work"; @@ -30,17 +31,27 @@ type Read = * is kept on the device. */ export function AdminNewJobSharing() { + const { scope, isCurrent } = useAccountRequestScope(); + const [owner, setOwner] = useState(scope); const [attempt, setAttempt] = useState(0); - const [read, setRead] = useState<{ readonly key: number; readonly value: Read } | null>(null); + const [read, setRead] = useState<{ readonly key: number; readonly scope: string; readonly value: Read } | null>(null); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); + if (owner !== scope) { + setOwner(scope); + setSaving(false); + setSaveError(null); + } + useEffect(() => { const controller = new AbortController(); fetchStarterSharing({ signal: controller.signal }).then( - (outcome) => + (outcome) => { + if (controller.signal.aborted || !isCurrent()) return; setRead({ key: attempt, + scope, value: outcome.status === "ok" ? { status: "ok", share: outcome.share } @@ -49,28 +60,32 @@ export function AdminNewJobSharing() { offline: outcome.status === "offline", message: outcome.status === "error" ? outcome.message : null, }, - }), + }); + }, () => { // Aborted: a newer read replaced this one. }, ); return () => controller.abort(); - }, [attempt]); + }, [attempt, scope, isCurrent]); const retry = useCallback(() => { + if (!isCurrent()) return; setSaveError(null); setAttempt((n) => n + 1); - }, []); + }, [isCurrent]); - const state: Read = read?.key === attempt ? read.value : { status: "loading" }; + const state: Read = read?.scope === scope && read.key === attempt ? read.value : { status: "loading" }; async function toggle(next: boolean) { + if (!isCurrent()) return; setSaving(true); setSaveError(null); const outcome = await saveStarterSharing(next); + if (!isCurrent()) return; setSaving(false); if (outcome.status === "ok") { - setRead({ key: attempt, value: { status: "ok", share: outcome.share } }); + setRead({ key: attempt, scope, value: { status: "ok", share: outcome.share } }); return; } const stillOn = !next; diff --git a/src/components/calculators/clinical-math-panel.tsx b/src/components/calculators/clinical-math-panel.tsx index 2f9d3fd516..eeaa3fd5e9 100644 --- a/src/components/calculators/clinical-math-panel.tsx +++ b/src/components/calculators/clinical-math-panel.tsx @@ -17,6 +17,7 @@ import { type CreatinineUnit, type LithiumIndication, } from "@/lib/calculators/clinical-math"; +import clinicalMathGovernance from "@/lib/calculators/clinical-math/governance.json"; type ActiveTab = "renal" | "qtc" | "lithium"; @@ -134,6 +135,30 @@ export function ClinicalMathPanel({ setTabletIncrementMg, } = state ?? ownState; + // Arithmetic tests are not dedicated clinical validation. Keep all draft outputs, + // including dose and management advice, withheld while that review is pending. + if ( + clinicalMathGovernance.releaseStatus !== "available" || + clinicalMathGovernance.clinicalReview.status !== "approved" || + !clinicalMathGovernance.clinicalReview.reviewer || + !clinicalMathGovernance.clinicalReview.reviewedOn || + !clinicalMathGovernance.clinicalReview.approvalEvidence + ) { + return ( +
+

Physiological calculators unavailable

+

+ Renal, QTc and lithium tools are withheld pending dedicated clinical validation. Use your approved local + reference and clinical assessment. +

+
+ ); + } + const parsedAge = parseStrictNumber(age); const parsedWeight = parseStrictNumber(weightKg); const parsedHeight = heightCm.trim() === "" ? undefined : parseStrictNumber(heightCm); diff --git a/src/components/roster/manage/roster-cover-tab.tsx b/src/components/roster/manage/roster-cover-tab.tsx index 52b836a921..c5c5ed1187 100644 --- a/src/components/roster/manage/roster-cover-tab.tsx +++ b/src/components/roster/manage/roster-cover-tab.tsx @@ -8,8 +8,10 @@ import { WorkStateLoading, WorkStateNotice } from "@/components/mode-kit/work-st import { Sheet } from "@/components/ui/sheet"; import { cn } from "@/components/ui-primitives"; import { TextField } from "@/components/ui/text-field"; +import { useWorkTimeZone } from "@/components/work-time/use-work-time-zone"; import { fetchRosterRead, postRosterAction, useRosterRead } from "@/components/roster/use-roster-team"; -import { addDaysToDate, formatPerthDay, perthDateOf } from "@/lib/roster/shifts/perth-time"; +import { addDaysToDate, formatPerthDay, perthDateOf, perthWallToIso } from "@/lib/roster/shifts/perth-time"; +import { workTimeZoneLabel } from "@/lib/work-time/zones"; import { coverForDay, needsOn } from "@/lib/roster/team/cover"; import { fairnessCounts } from "@/lib/roster/team/fairness"; import { openShiftCandidates } from "@/lib/roster/team/eligibility"; @@ -148,6 +150,7 @@ export function RosterCoverGap({ serviceId: string; onPosted: () => void; }) { + const { zone } = useWorkTimeZone(); const [start, setStart] = useState(""); const [end, setEnd] = useState(""); const [code, setCode] = useState(""); @@ -161,14 +164,15 @@ export function RosterCoverGap({ Gap: {formatPerthDay(date)} {need.kind}. Clarify the required grade before posting this shift.

); - const startsAt = start ? `${date}T${start}:00+08:00` : ""; - const endsAt = end ? `${end <= start ? addDaysToDate(date, 1) : date}T${end}:00+08:00` : ""; + const startsAt = start ? perthWallToIso(date, start, zone) : null; + const endsAt = end ? perthWallToIso(end <= start ? addDaysToDate(date, 1) : date, end, zone) : null; + const invalidTime = Boolean(start && end && (!startsAt || !endsAt)); const candidates = startsAt && endsAt ? openShiftCandidates(assignments, { startsAt, endsAt, minGrade: need.grade }, overview.settings, null) : []; async function post() { - if (!start || !end || !code.trim() || busy || need.kind === "leave") return; + if (!startsAt || !endsAt || !code.trim() || busy || need.kind === "leave") return; setBusy(true); const result = await postRosterAction(serviceId, { action: "open.post", @@ -206,11 +210,26 @@ export function RosterCoverGap({ setOpened(false)} title="Post gap" description={summary}>
- setStart(e.target.value)} /> - setEnd(e.target.value)} /> + setStart(e.target.value)} + /> + setEnd(e.target.value)} + />
setCode(e.target.value)} /> - {start && end ? ( + {invalidTime ? ( +

+ Choose valid times in your work time zone. A daylight-saving clock change can skip a time. +

+ ) : null} + {startsAt && endsAt ? (

{candidates.length ? `${candidates.map((person) => person.name ?? "Team member").join(", ")} may be able to take it. Team rules are checked again on acceptance.` @@ -218,7 +237,7 @@ export function RosterCoverGap({

) : null} {message ?

{message}

: null} -
diff --git a/src/components/roster/roster-month-page.tsx b/src/components/roster/roster-month-page.tsx index a4093d9784..c5092895de 100644 --- a/src/components/roster/roster-month-page.tsx +++ b/src/components/roster/roster-month-page.tsx @@ -240,7 +240,7 @@ export function RosterMonthPage({ now: pinnedNow }: { readonly now?: Date } = {} const assignments = useRosterRead(sheetDate ? oneTeamId : null, "assignments", sheetRange); const links = useRosterLinks(); const settings = useRosterSettings(); - // Rotations (and later booked courses): empty for readers outside the rotation preferences preview. + // Successful sources remain visible even when another calendar source fails. const calendar = useWorkCalendarEntries(); const holidays = WA_PUBLIC_HOLIDAY_DATES; @@ -353,6 +353,21 @@ export function RosterMonthPage({ now: pinnedNow }: { readonly now?: Date } = {} <> {notice ? {notice} : null} {shifts.teamMessage ? {shifts.teamMessage} : null} + {( + [ + ["rotations", "Rotations"], + ["bookings", "Course bookings"], + ] as const + ).map(([id, label]) => + calendar.sources[id]?.status === "error" ? ( + + {label} could not be loaded. Your shifts and other loaded calendar entries are still shown.{" "} + calendar.retry(id)}> + Retry {label.toLowerCase()} + + + ) : null, + )} ); diff --git a/src/components/roster/roster-settings-page.tsx b/src/components/roster/roster-settings-page.tsx index e26dd1a371..699239d633 100644 --- a/src/components/roster/roster-settings-page.tsx +++ b/src/components/roster/roster-settings-page.tsx @@ -28,6 +28,7 @@ import { rosterOutlineButton, } from "./roster-list"; import { describeLinkFailure, useRosterLinks } from "./use-roster-links"; +import { useAccountRequestScope } from "@/components/use-account-request-scope"; import { useRosterSettings } from "./use-roster-settings"; import { useRosterShifts } from "./use-roster-shifts"; import { RosterPageHeader } from "./roster-ui"; @@ -60,6 +61,8 @@ const EVENING_BEFORE: ReminderLeadTime = "evening-before"; type DeleteState = "idle" | "pending" | "deleting" | "deleted"; export function RosterSettingsPage() { + const { scope, isCurrent } = useAccountRequestScope(); + const [owner, setOwner] = useState(scope); const shifts = useRosterShifts(); const links = useRosterLinks(); const settings = useRosterSettings(); @@ -72,6 +75,14 @@ export function RosterSettingsPage() { >(null); const [confirmBusy, setConfirmBusy] = useState(false); + if (owner !== scope) { + setOwner(scope); + setDeleteState("idle"); + setNotice(null); + setConfirm(null); + setConfirmBusy(false); + } + const reminderOn = preferences.reminders.types[SHIFTS_REMINDER]?.calendarAlert === EVENING_BEFORE; const calendarShifts = settings.settings.calendarShifts; @@ -85,12 +96,14 @@ export function RosterSettingsPage() { const { deleteAll, reload: reloadShifts } = shifts; const { reload: reloadLinks } = links; const sendDelete = useCallback(() => { + if (!isCurrent()) return; if (timer.current !== null) window.clearTimeout(timer.current); timer.current = null; setDeleteState("deleting"); // Committed once the undo window ends: `keepalive` lets the request outlive // the page if it is closed or left now. Only the pending window cancels. void deleteAll({ keepalive: true }).then((result) => { + if (!isCurrent()) return; // A delete that removed your own data but left some team requests is done, with a note. setDeleteState(result.ok ? "deleted" : "idle"); if (result.ok) { @@ -105,7 +118,7 @@ export function RosterSettingsPage() { text: `${result.message ?? "Your roster data couldn't be deleted."} Some of it may already be gone. This page now shows what is left.`, }); }); - }, [deleteAll, reloadShifts, reloadLinks]); + }, [deleteAll, reloadShifts, reloadLinks, isCurrent]); // Leaving the page during the 30 seconds, closed or navigated away from // inside the app, cancels the pending delete: nothing is sent. @@ -126,27 +139,32 @@ export function RosterSettingsPage() { window.clearTimeout(timer.current); timer.current = null; }, - [], + [scope], ); function startDelete() { + if (!isCurrent()) return; setNotice(null); setDeleteState("pending"); timer.current = window.setTimeout(sendDelete, ROSTER_DELETE_UNDO_MS); } function undoDelete() { + if (!isCurrent()) return; if (timer.current !== null) window.clearTimeout(timer.current); timer.current = null; setDeleteState("idle"); } async function toggleCalendarShifts() { + if (!isCurrent()) return; const failure = await settings.update({ calendarShifts: !calendarShifts }); + if (!isCurrent()) return; if (failure) setNotice({ tone: "warning", text: failure }); } function toggleReminder() { + if (!isCurrent()) return; setPreference( "reminders", updateReminderType(preferences.reminders, SHIFTS_REMINDER, { @@ -156,19 +174,24 @@ export function RosterSettingsPage() { } async function removeWorkplace(name: string) { + if (!isCurrent()) return; // Its calendar links and imported shifts go together, so no refresh brings it back; then its codes. const removed = await shifts.removeWorkplace(name); + if (!isCurrent()) return; if (removed) { setNotice({ tone: "warning", text: removed }); return; } void links.reload(); const failure = await settings.update({ codes: { [name]: null } }); + if (!isCurrent()) return; setNotice(failure ? { tone: "warning", text: failure } : { tone: "neutral", text: "Removed" }); } async function linkAction(action: Promise, done: string) { + if (!isCurrent()) return; const failure = await action; + if (!isCurrent()) return; setNotice(failure ? { tone: "warning", text: failure } : { tone: "neutral", text: done }); } @@ -192,10 +215,12 @@ export function RosterSettingsPage() { } async function confirmRemoval() { + if (!isCurrent()) return; if (!confirm) return; setConfirmBusy(true); if (confirm.kind === "workplace") await removeWorkplace(confirm.name); else await linkAction(links.remove(confirm.id), "Removed"); + if (!isCurrent()) return; setConfirmBusy(false); setConfirm(null); } diff --git a/src/components/roster/use-roster-links.ts b/src/components/roster/use-roster-links.ts index 717b0ab2eb..7f799a2b7b 100644 --- a/src/components/roster/use-roster-links.ts +++ b/src/components/roster/use-roster-links.ts @@ -2,6 +2,8 @@ import { useCallback, useEffect, useState } from "react"; +import { useAccountRequestScope } from "@/components/use-account-request-scope"; + import type { RosterCalendarLink, RosterCalendarLinkReason } from "@/lib/roster/calendar-links"; /** @@ -165,47 +167,72 @@ async function fetchLinks(signal?: AbortSignal): Promise { } export function useRosterLinks(): RosterLinksState { + const { scope, isCurrent } = useAccountRequestScope(); + const [owner, setOwner] = useState(scope); const [status, setStatus] = useState("loading"); const [links, setLinks] = useState([]); - const apply = useCallback((result: LoadedLinks) => { - if (result === "aborted") return; - if (result === "signed-out" || result === "error") { - setStatus(result); - return; - } - setLinks(result); - setStatus("ready"); - }, []); + if (owner !== scope) { + setOwner(scope); + setStatus("loading"); + setLinks([]); + } - const load = useCallback(async () => apply(await fetchLinks()), [apply]); + const apply = useCallback( + (result: LoadedLinks) => { + if (!isCurrent() || result === "aborted") return; + if (result === "signed-out" || result === "error") { + setStatus(result); + return; + } + setLinks(result); + setStatus("ready"); + }, + [isCurrent], + ); + + const load = useCallback(async () => { + if (isCurrent()) apply(await fetchLinks()); + }, [apply, isCurrent]); useEffect(() => { const controller = new AbortController(); - fetchLinks(controller.signal).then(apply, () => undefined); + fetchLinks(controller.signal).then( + (result) => { + if (!controller.signal.aborted) apply(result); + }, + () => undefined, + ); return () => controller.abort(); }, [apply]); /** Refresh one link. Null when it worked, or the sentence saying why not. */ - const refreshOne = useCallback(async (id: string): Promise => { - try { - const response = await fetch(`${LINKS_URL}/refresh`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id }), - }); - const payload = await response.json().catch(() => null); - if (!response.ok) return describeLinkFailure(errorCodeOf(payload)) ?? "Refresh did not work."; - const result = readRefreshResults(payload)?.find((item) => item.id === id); - if (!result) return "Refresh did not work."; - return result.ok ? null : (describeLinkFailure(result.reason ?? null) ?? "Refresh did not work."); - } catch { - return "That link could not be refreshed. Check your connection and try again."; - } - }, []); + const refreshOne = useCallback( + async (id: string): Promise => { + if (!isCurrent()) return null; + try { + const response = await fetch(`${LINKS_URL}/refresh`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id }), + }); + const payload = await response.json().catch(() => null); + if (!isCurrent()) return null; + if (!response.ok) return describeLinkFailure(errorCodeOf(payload)) ?? "Refresh did not work."; + const result = readRefreshResults(payload)?.find((item) => item.id === id); + if (!result) return "Refresh did not work."; + return result.ok ? null : (describeLinkFailure(result.reason ?? null) ?? "Refresh did not work."); + } catch { + if (!isCurrent()) return null; + return "That link could not be refreshed. Check your connection and try again."; + } + }, + [isCurrent], + ); const add = useCallback( async (url: string, workplace: string | null) => { + if (!isCurrent()) return null; try { const response = await fetch(LINKS_URL, { method: "POST", @@ -213,6 +240,7 @@ export function useRosterLinks(): RosterLinksState { body: JSON.stringify({ url, workplace }), }); const payload = await response.json().catch(() => null); + if (!isCurrent()) return null; if (!response.ok) { const code = errorCodeOf(payload); if (code === "duplicate_workplace") return "You already have a calendar link for that workplace."; @@ -225,34 +253,42 @@ export function useRosterLinks(): RosterLinksState { await load(); return null; } catch { + if (!isCurrent()) return null; return "That link could not be added. Check your connection and try again."; } }, - [load, refreshOne], + [load, refreshOne, isCurrent], ); - const remove = useCallback(async (id: string) => { - try { - const response = await fetch(LINKS_URL, { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id }), - }); - if (!response.ok) return "That link could not be removed."; - setLinks((current) => current.filter((link) => link.id !== id)); - return null; - } catch { - return "That link could not be removed. Check your connection and try again."; - } - }, []); + const remove = useCallback( + async (id: string) => { + if (!isCurrent()) return null; + try { + const response = await fetch(LINKS_URL, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id }), + }); + if (!isCurrent()) return null; + if (!response.ok) return "That link could not be removed."; + setLinks((current) => current.filter((link) => link.id !== id)); + return null; + } catch { + if (!isCurrent()) return null; + return "That link could not be removed. Check your connection and try again."; + } + }, + [isCurrent], + ); const refresh = useCallback( async (id: string) => { const failure = await refreshOne(id); + if (!isCurrent()) return null; await load(); return failure; }, - [load, refreshOne], + [load, refreshOne, isCurrent], ); return { status, links, add, remove, refresh, reload: load }; diff --git a/src/components/roster/use-roster-settings.ts b/src/components/roster/use-roster-settings.ts index e450ddecd4..4a4005f8a1 100644 --- a/src/components/roster/use-roster-settings.ts +++ b/src/components/roster/use-roster-settings.ts @@ -1,6 +1,8 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; + +import { useAccountRequestScope } from "@/components/use-account-request-scope"; import type { CodeMeaning } from "@/lib/roster/import/grid"; @@ -84,39 +86,55 @@ export function readRosterSettings(payload: unknown): RosterSettings | null { } export function useRosterSettings(): RosterSettingsState { + const { scope, isCurrent } = useAccountRequestScope(); + const [owner, setOwner] = useState(scope); const [status, setStatus] = useState("loading"); const [settings, setSettings] = useState(EMPTY_ROSTER_SETTINGS); const current = useRef(settings); + // Same-account writes remain serial; a new account starts an independent queue. + const queue = useRef>(Promise.resolve()); const accept = useCallback((next: RosterSettings) => { current.current = next; setSettings(next); }, []); + if (owner !== scope) { + setOwner(scope); + setSettings(EMPTY_ROSTER_SETTINGS); + setStatus("loading"); + } + useLayoutEffect(() => { + current.current = EMPTY_ROSTER_SETTINGS; + queue.current = Promise.resolve(); + }, [scope]); + useEffect(() => { const controller = new AbortController(); fetch(SETTINGS_URL, { cache: "no-store", signal: controller.signal }) .then(async (response) => { + if (controller.signal.aborted || !isCurrent()) return; if (response.status === 401) { setStatus("signed-out"); return; } if (!response.ok) throw new Error(`status ${response.status}`); - accept(readRosterSettings(await response.json().catch(() => null)) ?? EMPTY_ROSTER_SETTINGS); + const saved = readRosterSettings(await response.json().catch(() => null)); + if (controller.signal.aborted || !isCurrent()) return; + accept(saved ?? EMPTY_ROSTER_SETTINGS); setStatus("ready"); }) .catch((error: unknown) => { + if (controller.signal.aborted || !isCurrent()) return; if ((error as { name?: string })?.name === "AbortError") return; setStatus("error"); }); return () => controller.abort(); - }, [accept]); - - // Writes run one at a time so an older completion (or its rollback) can never overwrite a newer state. - const queue = useRef>(Promise.resolve()); + }, [accept, isCurrent]); const write = useCallback( async (patch: RosterSettingsPatch) => { + if (!isCurrent()) return null; const before = current.current; accept(applyRosterSettingsPatch(before, patch)); try { @@ -125,28 +143,32 @@ export function useRosterSettings(): RosterSettingsState { headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); + if (!isCurrent()) return null; if (!response.ok) { accept(before); return response.status === 401 ? "Sign in to change Roster settings." : "That setting could not be saved."; } const saved = readRosterSettings(await response.json().catch(() => null)); + if (!isCurrent()) return null; if (saved) accept(saved); return null; } catch { + if (!isCurrent()) return null; accept(before); return "That setting could not be saved. Check your connection and try again."; } }, - [accept], + [accept, isCurrent], ); const update = useCallback( (patch: RosterSettingsPatch) => { + if (!isCurrent()) return Promise.resolve(null); const next = queue.current.then(() => write(patch)); queue.current = next.catch(() => undefined); return next; }, - [write], + [write, isCurrent], ); return { status, settings, update }; diff --git a/src/components/roster/use-roster-shifts.ts b/src/components/roster/use-roster-shifts.ts index e180a352df..5cf35175c4 100644 --- a/src/components/roster/use-roster-shifts.ts +++ b/src/components/roster/use-roster-shifts.ts @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useState } from "react"; +import { useAccountRequestScope } from "@/components/use-account-request-scope"; import { useRosterTeams, rosterTeamUrl } from "./use-roster-team"; import { mergeMyShifts, type RosterDisplayShift } from "@/lib/roster/team/team-view"; import { rosterAssignmentsSchema, type RosterAssignment, type RosterTeam } from "@/lib/roster/team/model"; @@ -134,6 +135,8 @@ export function useRosterShifts( teamRange?: { from: string; to: string }, options?: { readonly ownHistory?: boolean; readonly skipWhenUnauthenticated?: boolean }, ): RosterShiftsState { + const { scope, isCurrent } = useAccountRequestScope(); + const [owner, setOwner] = useState(scope); const { zone } = useWorkTimeZone(); const skipGuest = Boolean(options?.skipWhenUnauthenticated && !hasSupabaseAuthSession()); const teams = useRosterTeams({ skip: skipGuest }); @@ -170,14 +173,14 @@ export function useRosterShifts( .then(async (response) => { if (!response.ok) throw new Error("older shifts unavailable"); const payload = await readPayload(response); - if (!controller.signal.aborted) + if (!controller.signal.aborted && isCurrent()) setHistory({ from, to: historyTo, shifts: payload.shifts ?? [], failed: false }); }) .catch(() => { - if (!controller.signal.aborted) setHistory({ from, to: historyTo, shifts: [], failed: true }); + if (!controller.signal.aborted && isCurrent()) setHistory({ from, to: historyTo, shifts: [], failed: true }); }); return () => controller.abort(); - }, [wantsHistory, from, historyTo, historyReload, options?.skipWhenUnauthenticated]); + }, [wantsHistory, from, historyTo, historyReload, options?.skipWhenUnauthenticated, isCurrent]); const [teamData, setTeamData] = useState<{ payload: typeof teams.data; owner: string; @@ -207,11 +210,11 @@ export function useRosterShifts( }), ) .then((rows) => { - if (!controller.signal.aborted) + if (!controller.signal.aborted && isCurrent()) setTeamData({ payload: teamPayload, owner: actorId, from, to, rows, message: null }); }) .catch(() => { - if (!controller.signal.aborted) + if (!controller.signal.aborted && isCurrent()) setTeamData({ payload: teamPayload, owner: actorId, @@ -222,7 +225,7 @@ export function useRosterShifts( }); }); return () => controller.abort(); - }, [actorId, teamPayload, from, to]); + }, [actorId, teamPayload, from, to, isCurrent]); const [status, setStatus] = useState(() => options?.skipWhenUnauthenticated && !hasSupabaseAuthSession() ? "signed-out" : "loading", ); @@ -231,40 +234,52 @@ export function useRosterShifts( const [demoMode, setDemoMode] = useState(false); const [sample, setSample] = useState(false); - const accept = useCallback((payload: Payload) => { - setShifts(payload.shifts ?? []); - setLatestImport(payload.latestImport ?? null); - setDemoMode(Boolean(payload.demoMode)); - setSample(Boolean(payload.sample)); - setStatus("ready"); - // Tells auto mode whether Roster has real shifts (the sample gate waits for it). - if (!payload.demoMode && !payload.sample) { - reportAreaData("rost", (payload.shifts?.length ?? 0) > 0 || payload.latestImport ? "has-data" : "empty"); - } - }, []); + const accept = useCallback( + (payload: Payload) => { + if (!isCurrent()) return; + setShifts(payload.shifts ?? []); + setLatestImport(payload.latestImport ?? null); + setDemoMode(Boolean(payload.demoMode)); + setSample(Boolean(payload.sample)); + setStatus("ready"); + // Tells auto mode whether Roster has real shifts (the sample gate waits for it). + if (!payload.demoMode && !payload.sample) { + reportAreaData("rost", (payload.shifts?.length ?? 0) > 0 || payload.latestImport ? "has-data" : "empty"); + } + }, + [isCurrent], + ); const apply = useCallback( (result: Loaded) => { - if (result === "aborted") return; + if (!isCurrent() || result === "aborted") return; if (result === "signed-out" || result === "error") setStatus(result); else accept(result); }, - [accept], + [accept, isCurrent], ); - const load = useCallback(async () => apply(await fetchShifts()), [apply]); + const load = useCallback(async () => { + if (isCurrent()) apply(await fetchShifts()); + }, [apply, isCurrent]); useEffect(() => { if (options?.skipWhenUnauthenticated && !hasSupabaseAuthSession()) { return; } const controller = new AbortController(); - fetchShifts(controller.signal).then(apply, () => undefined); + fetchShifts(controller.signal).then( + (result) => { + if (!controller.signal.aborted) apply(result); + }, + () => undefined, + ); return () => controller.abort(); }, [apply, options?.skipWhenUnauthenticated]); const save = useCallback( async (request: OnCallShiftImportRequest) => { + if (!isCurrent()) return null; try { const response = await fetch(ROSTER_SHIFTS_URL, { method: "POST", @@ -272,22 +287,37 @@ export function useRosterShifts( body: JSON.stringify(request), }); const payload = await readPayload(response); + if (!isCurrent()) return null; if (response.status === 401) return "Sign in to save your roster."; if (!response.ok) return errorText(payload, "Your roster could not be saved. Try again."); accept(payload); refreshHistory(); return null; } catch { + if (!isCurrent()) return null; return "Your roster could not be saved. Check your connection and try again."; } }, - [accept, refreshHistory], + [isCurrent, accept, refreshHistory], ); const [lastManualConflicts, setLastManualConflicts] = useState([]); + if (owner !== scope) { + setOwner(scope); + setStatus(skipGuest ? "signed-out" : "loading"); + setShifts([]); + setLatestImport(null); + setDemoMode(false); + setSample(false); + setHistory(null); + setTeamData(null); + setLastManualConflicts([]); + } + const addManual = useCallback( async (request: OnCallManualShiftRequest) => { + if (!isCurrent()) return null; try { const response = await fetch(`${ROSTER_SHIFTS_URL}/manual`, { method: "POST", @@ -295,61 +325,72 @@ export function useRosterShifts( body: JSON.stringify(request), }); const payload = await readPayload(response); + if (!isCurrent()) return null; if (response.status === 401) return "Sign in to add a shift."; if (!response.ok) return errorText(payload, "That shift could not be saved. Try again."); setLastManualConflicts(Array.isArray(payload.conflicts) ? payload.conflicts : []); await load(); + if (!isCurrent()) return null; refreshHistory(); return null; } catch { + if (!isCurrent()) return null; return "That shift could not be saved. Check your connection and try again."; } }, - [load, refreshHistory], + [isCurrent, load, refreshHistory], ); const removeSeries = useCallback( async (seriesId: string) => { + if (!isCurrent()) return null; try { const response = await fetch(`${ROSTER_SHIFTS_URL}/manual/${encodeURIComponent(seriesId)}`, { method: "DELETE", }); + if (!isCurrent()) return null; if (!response.ok) return errorText(await readPayload(response), "That shift could not be removed. Try again."); setShifts((current) => current.filter((shift) => shift.seriesId !== seriesId)); refreshHistory(); return null; } catch { + if (!isCurrent()) return null; return "That shift could not be removed. Check your connection and try again."; } }, - [refreshHistory], + [isCurrent, refreshHistory], ); const deleteAll = useCallback( async (options?: { keepalive?: boolean }) => { + if (!isCurrent()) return { ok: false, message: null }; try { const response = await fetch(ROSTER_SHIFTS_URL, { method: "DELETE", keepalive: options?.keepalive ?? false }); const payload = await readPayload(response); + if (!isCurrent()) return { ok: false, message: null }; if (!response.ok) return { ok: false, message: errorText(payload, "Your data could not be deleted. Try again.") }; accept(payload); refreshHistory(); return { ok: true, message: payload.message ?? null }; } catch { + if (!isCurrent()) return { ok: false, message: null }; return { ok: false, message: "Your data could not be deleted. Check your connection and try again." }; } }, - [accept, refreshHistory], + [isCurrent, accept, refreshHistory], ); const removeWorkplace = useCallback( async (workplace: string) => { + if (!isCurrent()) return null; try { const response = await fetch(ROSTER_WORKPLACES_URL, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ workplace }), }); + if (!isCurrent()) return null; if (!response.ok) { return errorText(await readPayload(response), "That workplace could not be removed. Try again."); } @@ -359,25 +400,28 @@ export function useRosterShifts( refreshHistory(); return null; } catch { + if (!isCurrent()) return null; return "That workplace could not be removed. Check your connection and try again."; } }, - [refreshHistory], + [isCurrent, refreshHistory], ); const dismissChanges = useCallback(async () => { + if (!isCurrent()) return; const current = latestImport; if (!current || current.seenAt) return; setLatestImport({ ...current, seenAt: new Date().toISOString() }); await fetch(`${ROSTER_SHIFTS_URL}/imports/${current.id}`, { method: "PATCH" }).catch(() => undefined); - }, [latestImport]); + }, [latestImport, isCurrent]); const reloadTeams = teams.reload; const reload = useCallback(async () => { + if (!isCurrent()) return; reloadTeams(); setHistoryReload((count) => count + 1); await load(); - }, [reloadTeams, load]); + }, [reloadTeams, load, isCurrent]); const sameTeams = teamData && diff --git a/src/components/roster/use-roster-team.ts b/src/components/roster/use-roster-team.ts index 56cacd1c85..c9c5a44963 100644 --- a/src/components/roster/use-roster-team.ts +++ b/src/components/roster/use-roster-team.ts @@ -11,6 +11,7 @@ import { type RosterRules, type RosterTeam, } from "@/lib/roster/team/model"; +import { useAccountRequestScope } from "@/components/use-account-request-scope"; import { useExampleData } from "@/lib/example-data/store"; import { setRosterHasEnabledTeam } from "@/lib/teaching/page-visibility"; import { sharedGet } from "@/lib/shared-get"; @@ -78,10 +79,13 @@ async function load(url: string, what: RosterReadWhat | "teams", signal: Abor } function useLoaded(url: string | null, what: RosterReadWhat | "teams"): RosterReadState { + const { scope, isCurrent } = useAccountRequestScope(); // Each answer remembers the address it was read from, so a new address shows // "loading" at once instead of the previous team's data. A reload of the same // address keeps the current answer on screen until the fresh one arrives. - const [state, setState] = useState<(Omit, "reload"> & { url: string }) | null>(null); + const [state, setState] = useState<(Omit, "reload"> & { url: string; scope: string }) | null>( + null, + ); const [generation, setGeneration] = useState(0); const reload = useCallback(() => setGeneration((value) => value + 1), []); @@ -89,17 +93,18 @@ function useLoaded(url: string | null, what: RosterReadWhat | "teams"): Roste if (!url) return; const controller = new AbortController(); void load(url, what, controller.signal).then((result) => { - if (result.status === "aborted" || controller.signal.aborted) return; + if (result.status === "aborted" || controller.signal.aborted || !isCurrent()) return; if (result.status === "ready") { - setState({ url, status: "ready", data: result.data, message: null, readAt: result.readAt }); + setState({ url, scope, status: "ready", data: result.data, message: null, readAt: result.readAt }); } else { - setState({ url, status: result.status, data: null, message: result.message, readAt: null }); + setState({ url, scope, status: result.status, data: null, message: result.message, readAt: null }); } }); return () => controller.abort(); - }, [url, what, generation]); + }, [url, what, generation, scope, isCurrent]); - if (!state || state.url !== url) return { status: "loading", data: null, message: null, readAt: null, reload }; + if (!state || state.url !== url || state.scope !== scope) + return { status: "loading", data: null, message: null, readAt: null, reload }; return { status: state.status, data: state.data, message: state.message, readAt: state.readAt, reload }; } diff --git a/src/components/teaching/organise-model.ts b/src/components/teaching/organise-model.ts index 713b21f043..460c730eed 100644 --- a/src/components/teaching/organise-model.ts +++ b/src/components/teaching/organise-model.ts @@ -12,6 +12,7 @@ import { } from "@/components/teaching/teaching-dates"; import type { LedgerGroup } from "@/components/teaching/teaching-modules"; import { withUnit } from "@/components/teaching/teaching-number"; +import { perthWallToIso } from "@/lib/roster/shifts/perth-time"; import { attendanceLabels, teachingCpdEntryHref, @@ -34,7 +35,7 @@ export type SeriesRow = ModelSeriesRow; export type { GroupRow }; export type OrganiseMember = z.infer["members"][number]; export type OrganiseRead = { series: SeriesRow[]; groups: GroupRow[]; members: OrganiseMember[] }; -export type Risk = { occurrenceId: string; rule: "room" | "clash"; text: string }; +export type Risk = { occurrenceId: string; rule: "room" | "clash" | "time"; text: string }; export type ChangeDraft = { status: "moved" | "cancelled"; date: string; @@ -149,12 +150,17 @@ export function draftFor(session: SessionSummary): ChangeDraft { }; } -/** Perth has no daylight saving, so +08:00 is exact. An unreadable date or time keeps the session's own. */ +/** Work-zone wall clock to instant. An unreadable or skipped time keeps the session's own. */ export function afterChange(session: SessionSummary, draft: ChangeDraft): SessionSummary { if (draft.status === "cancelled") return { ...session, status: "cancelled" }; - const start = Date.parse(`${draft.date}T${draft.startTime}:00+08:00`); - const startsAt = Number.isFinite(start) ? new Date(start).toISOString() : session.startsAt; - const endsAt = Number.isFinite(start) ? new Date(start + draft.minutes * 60_000).toISOString() : session.endsAt; + // Preserve the exact instant for a room-only edit, including an ambiguous DST clock time. + const sameClock = draft.date === perthDateKey(session.startsAt) && draft.startTime === perthTime(session.startsAt); + const converted = sameClock ? session.startsAt : perthWallToIso(draft.date, draft.startTime); + const startsAt = converted ?? session.startsAt; + const endsAt = + !converted || (sameClock && draft.minutes === durationMinutes(session.startsAt, session.endsAt)) + ? session.endsAt + : new Date(Date.parse(converted) + draft.minutes * 60_000).toISOString(); return { ...session, status: "moved", startsAt, endsAt, venue: draft.venue.trim() || null }; } @@ -170,6 +176,10 @@ export function draftChangesSomething(session: SessionSummary, draft: ChangeDraf } export function changeRisks(session: SessionSummary, draft: ChangeDraft, others: readonly SessionSummary[]): Risk[] { + if (draft.status !== "cancelled" && !perthWallToIso(draft.date, draft.startTime)) + return [ + { occurrenceId: session.occurrenceId, rule: "time", text: "Choose a valid date and time in your work time zone" }, + ]; const after = afterChange(session, draft); return sessionRisks([after, ...others.filter((o) => o.occurrenceId !== session.occurrenceId)]).filter( (r) => r.occurrenceId === session.occurrenceId, diff --git a/src/components/use-account-request-scope.ts b/src/components/use-account-request-scope.ts new file mode 100644 index 0000000000..d923240fc9 --- /dev/null +++ b/src/components/use-account-request-scope.ts @@ -0,0 +1,34 @@ +"use client"; + +import { useCallback, useLayoutEffect, useRef } from "react"; + +import { useAuthSession } from "@/lib/supabase/client"; + +function useOptionalAuth() { + try { + return useAuthSession(); + } catch (error) { + if (error instanceof Error && error.message === "useAuthSession must be used within AuthProvider.") return null; + throw error; + } +} + +/** Capture the identity of a callback, including callbacks retained by queues or timers. */ +export function useAccountRequestScope() { + const auth = useOptionalAuth(); + const scope = auth ? `${auth.authEpoch}:${auth.status}:${auth.session?.user.id ?? ""}` : "standalone"; + const epoch = auth?.authEpoch; + const isAuthEpochCurrent = auth?.isAuthEpochCurrent; + const current = useRef(scope); + useLayoutEffect(() => { + current.current = scope; + return () => { + current.current = null; + }; + }, [scope]); + const isCurrent = useCallback( + () => current.current === scope && (epoch === undefined || !isAuthEpochCurrent || isAuthEpochCurrent(epoch)), + [epoch, isAuthEpochCurrent, scope], + ); + return { scope, isCurrent }; +} diff --git a/src/components/work-calendar/use-work-calendar-entries.ts b/src/components/work-calendar/use-work-calendar-entries.ts index c3c4f3dc49..fad5acdfd0 100644 --- a/src/components/work-calendar/use-work-calendar-entries.ts +++ b/src/components/work-calendar/use-work-calendar-entries.ts @@ -13,8 +13,8 @@ import { mergeEntries, type WorkCalendarEntry } from "@/lib/work-calendar/entrie * hides its screen, and then adds no entries. * * `status` is "loading" while any source is still loading and "ready" after. - * A source that failed or is not available yet adds nothing: the calendar - * views stay quiet about it, and the feature's own page says what went wrong. + * A source that failed or is not available yet adds nothing. Callers inspect + * `sources` to disclose failed reads alongside entries from successful sources. */ export type WorkCalendarStatus = "off" | "loading" | "ready"; @@ -23,8 +23,8 @@ export type WorkCalendarEntriesRead = { readonly entries: readonly WorkCalendarEntry[]; /** Each source's own read, by source id. */ readonly sources: Readonly>; - /** Reads every source that failed again. */ - readonly retry: () => void; + /** Reads a failed source again, or every failed source when no id is given. */ + readonly retry: (sourceId?: string) => void; }; export function useWorkCalendarEntries({ @@ -42,8 +42,10 @@ export function useWorkCalendarEntries({ useEffect(() => { latest.current = reads; }); - const retry = useCallback(() => { - for (const read of latest.current) if (read.status === "error") read.retry?.(); + const retry = useCallback((sourceId?: string) => { + latest.current.forEach((read, index) => { + if (read.status === "error" && (!sourceId || WORK_CALENDAR_SOURCES[index]!.id === sourceId)) read.retry?.(); + }); }, []); return useMemo(() => { const parsed = JSON.parse(signature) as WorkCalendarSourceRead[]; diff --git a/src/components/work-screens/admin/pay-page.tsx b/src/components/work-screens/admin/pay-page.tsx index 84fd6a9f3d..afe5b85557 100644 --- a/src/components/work-screens/admin/pay-page.tsx +++ b/src/components/work-screens/admin/pay-page.tsx @@ -54,7 +54,7 @@ import { import { formatRecordedDate } from "@/lib/admin/renewal-dates"; import { copyTextToClipboard } from "@/lib/copy-to-clipboard"; import { FATIGUE_RULE_SET } from "@/lib/roster/fatigue-rules-source"; -import { perthDateOf } from "@/lib/roster/shifts/perth-time"; +import { addDaysToDate, perthDateOf } from "@/lib/roster/shifts/perth-time"; import { ADMIN_WORK_SCREEN_HREFS } from "@/lib/work-screens/admin/hrefs"; import { guardExampleAction, isExampleRecord } from "@/lib/example-data/guards"; import { @@ -106,13 +106,19 @@ export function AdminPayPage({ now: pinned }: { now?: Date } = {}) { const [checking, setChecking] = useState(false); const [failed, setFailed] = useState(false); - const shifts = useRosterShifts(); const teams = useRosterTeams(); const enabledTeams = (Array.isArray(teams.data?.teams) ? teams.data.teams : []).filter((team) => team.enabled); const oneTeamId = enabledTeams.length === 1 ? enabledTeams[0]!.serviceId : null; const overview = useRosterRead(oneTeamId, "overview"); const anchor = overview.status === "ready" ? (overview.data?.settings?.payFortnightAnchor ?? null) : null; const windows = payWindows(today, anchor); + // The previous fortnight can precede the 21-day list. The dated own-history API + // reads UTC days, so include the day before its local start for early Australian + // shifts. Pay windows remain exact; summaries clip and the hook deduplicates IDs. + const shifts = useRosterShifts( + { from: addDaysToDate(windows[1].start, -1), to: windows[0].end }, + { ownHistory: true }, + ); const own = shifts.status === "ready" && !shifts.sample && !page.signedOut; const extraNow = useRosterExtraTime(shifts.shifts, now, windows[0], own); const extraBefore = useRosterExtraTime(shifts.shifts, now, windows[1], own); @@ -124,9 +130,13 @@ export function AdminPayPage({ now: pinned }: { now?: Date } = {}) { const roster: RosterRead = page.signedOut ? { kind: "none", why: "Signed in, your rostered hours fill in here from Roster." } - : shifts.status === "loading" || shifts.teamLoading || (oneTeamId !== null && overview.status === "loading") + : shifts.status === "loading" || + shifts.teamLoading || + shifts.teamRefreshing || + shifts.historyLoading || + (oneTeamId !== null && overview.status === "loading") ? { kind: "loading" } - : shifts.status === "error" || shifts.status === "signed-out" + : shifts.status === "error" || shifts.status === "signed-out" || shifts.historyFailed ? { kind: "failed", retry: () => void shifts.reload() } : shifts.sample && !shifts.demoMode ? { kind: "none", why: "Roster is showing example shifts. Add your own roster and your hours fill in here." } diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 31ac253789..e5f42cf603 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -8,104 +8,102 @@ import { captureRequestError } from "@/lib/observability/error-tracking"; export async function register() { // Single Sentry init path per runtime (sentry.*.config.ts). Do not also call // initializeErrorTracking() — a second Sentry.init() races privacy/sampling options. + // Keep every Node-only import inside this positive runtime guard: Next's Edge + // webpack compiler prunes this branch, but does not prune imports after a + // negative early return based only on unreachable execution flow. if (process.env.NEXT_RUNTIME === "nodejs") { await import("./sentry.server.config"); - } - if (process.env.NEXT_RUNTIME === "edge") { - await import("./sentry.edge.config"); - } + if (process.env.NODE_ENV !== "production") { + // Development and staging keep their fallbacks, but one of them is silent: without + // RAG_QUERY_HASH_SECRET answer feedback cannot work at all, and the UI's advice to + // "run the question again" can never fix it. Say so once, here, rather than leaving + // it to be discovered per rating (2026-09-02 audit, L44). + const { warnAnswerFeedbackDisabled } = await import("@/lib/env"); + warnAnswerFeedbackDisabled(); + return; + } - // Only the Node.js server runtime in production needs this gate. Development - // keeps its local/demo fallbacks, and the Edge runtime doesn't use the Node-only - // server configuration these checks validate. - if (process.env.NEXT_RUNTIME !== "nodejs") return; + // Playwright validates a real production build, but its runner must remain a + // provider-free demo. Permit that otherwise-invalid combination only for the + // runner's isolated output and inert loopback environment. A partial or + // externally-addressed configuration still falls through to the production + // guard below and fails closed. + if (process.env.PLAYWRIGHT_OFFLINE_MODE === "true") { + const isolatedOutput = /^\.next-playwright\/[a-z0-9-]+\/dist$/i.test(process.env.NEXT_DIST_DIR ?? ""); + const providerFree = + process.env.NEXT_PUBLIC_DEMO_MODE === "true" && + process.env.RAG_PROVIDER_MODE === "offline" && + process.env.NEXT_PUBLIC_SUPABASE_URL === "http://127.0.0.1:1" && + !process.env.SUPABASE_SERVICE_ROLE_KEY && + !process.env.OPENAI_API_KEY; + if (isolatedOutput && providerFree) return; + throw new Error("Refusing to start: invalid isolated Playwright offline environment."); + } - if (process.env.NODE_ENV !== "production") { - // Development and staging keep their fallbacks, but one of them is silent: without - // RAG_QUERY_HASH_SECRET answer feedback cannot work at all, and the UI's advice to - // "run the question again" can never fix it. Say so once, here, rather than leaving - // it to be discovered per rating (2026-09-02 audit, L44). - const { warnAnswerFeedbackDisabled } = await import("@/lib/env"); - warnAnswerFeedbackDisabled(); - return; - } + // Defense in depth: no-auth must never be active in a production build (even + // though isLocalNoAuthMode() already hard-guards on NODE_ENV). + if (process.env.LOCAL_NO_AUTH === "true" || process.env.NEXT_PUBLIC_LOCAL_NO_AUTH === "true") { + throw new Error("Refusing to start: local no-auth mode is enabled in a production build."); + } - // Playwright validates a real production build, but its runner must remain a - // provider-free demo. Permit that otherwise-invalid combination only for the - // runner's isolated output and inert loopback environment. A partial or - // externally-addressed configuration still falls through to the production - // guard below and fails closed. - if (process.env.PLAYWRIGHT_OFFLINE_MODE === "true") { - const isolatedOutput = /^\.next-playwright\/[a-z0-9-]+\/dist$/i.test(process.env.NEXT_DIST_DIR ?? ""); - const providerFree = - process.env.NEXT_PUBLIC_DEMO_MODE === "true" && - process.env.RAG_PROVIDER_MODE === "offline" && - process.env.NEXT_PUBLIC_SUPABASE_URL === "http://127.0.0.1:1" && - !process.env.SUPABASE_SERVICE_ROLE_KEY && - !process.env.OPENAI_API_KEY; - if (isolatedOutput && providerFree) return; - throw new Error("Refusing to start: invalid isolated Playwright offline environment."); - } + const { env, isDemoMode, requireOpenAIEnv, requireQueryHashSecret, requireSentryEnv, requireServerEnv } = + await import("@/lib/env"); - // Defense in depth: no-auth must never be active in a production build (even - // though isLocalNoAuthMode() already hard-guards on NODE_ENV). - if (process.env.LOCAL_NO_AUTH === "true" || process.env.NEXT_PUBLIC_LOCAL_NO_AUTH === "true") { - throw new Error("Refusing to start: local no-auth mode is enabled in a production build."); - } + // A clinical production server must run against real, configured backends — never + // in demo mode, which bypasses auth and serves canned content. + if (isDemoMode()) { + throw new Error( + "Refusing to start: demo mode is enabled in a production build. Unset NEXT_PUBLIC_DEMO_MODE and configure Supabase.", + ); + } - const { env, isDemoMode, requireOpenAIEnv, requireQueryHashSecret, requireSentryEnv, requireServerEnv } = - await import("@/lib/env"); + // Throw actionable errors now if the configured backend is missing or points at + // the wrong project, instead of failing per-request. Explicit offline deployments + // intentionally have no OpenAI credential; auto/openai modes still fail closed. + requireServerEnv(); + if (env.RAG_PROVIDER_MODE !== "offline") requireOpenAIEnv(); - // A clinical production server must run against real, configured backends — never - // in demo mode, which bypasses auth and serves canned content. - if (isDemoMode()) { - throw new Error( - "Refusing to start: demo mode is enabled in a production build. Unset NEXT_PUBLIC_DEMO_MODE and configure Supabase.", - ); - } + // A keyed HMAC secret must be present so clinical-query hashes written to the log + // tables are not reversible (PIA-2). Fail closed rather than degrade to weak SHA-256. + requireQueryHashSecret(); + // Runtime DSN consistency only. Sourcemap upload credentials are build-time + // and are gated in next.config.ts — not re-checked here. + requireSentryEnv(); - // Throw actionable errors now if the configured backend is missing or points at - // the wrong project, instead of failing per-request. Explicit offline deployments - // intentionally have no OpenAI credential; auto/openai modes still fail closed. - requireServerEnv(); - if (env.RAG_PROVIDER_MODE !== "offline") requireOpenAIEnv(); - - // A keyed HMAC secret must be present so clinical-query hashes written to the log - // tables are not reversible (PIA-2). Fail closed rather than degrade to weak SHA-256. - requireQueryHashSecret(); - // Runtime DSN consistency only. Sourcemap upload credentials are build-time - // and are gated in next.config.ts — not re-checked here. - requireSentryEnv(); - - // Warm rag_aliases so the first post-boot search skips the cold-cache DB RTT. - // Non-blocking: failures are logged via the catch handler below. - const { warmEnabledRagAliasCache } = await import("@/lib/rag/rag-retrieval-variants"); - warmEnabledRagAliasCache().catch((error) => { - console.warn("rag_aliases cache warmup failed; first request will retry.", { - message: error instanceof Error ? error.message : String(error), + // Warm rag_aliases so the first post-boot search skips the cold-cache DB RTT. + // Non-blocking: failures are logged via the catch handler below. + const { warmEnabledRagAliasCache } = await import("@/lib/rag/rag-retrieval-variants"); + warmEnabledRagAliasCache().catch((error) => { + console.warn("rag_aliases cache warmup failed; first request will retry.", { + message: error instanceof Error ? error.message : String(error), + }); }); - }); - // Warm the three catalogue kinds universal-search Promise.all's, serially, so an idle - // process's first federated search is not the cold connection that blows the 1200 ms - // budget (monitor #2919 / ledger #WFSMMT). Non-blocking; failures swallowed inside. - const { warmCanonicalCatalogueSearchCaches } = await import("@/lib/site-content/warm-catalogue-caches"); - void warmCanonicalCatalogueSearchCaches(); + // Warm the three catalogue kinds universal-search Promise.all's, serially, so an idle + // process's first federated search is not the cold connection that blows the 1200 ms + // budget (monitor #2919 / ledger #WFSMMT). Non-blocking; failures swallowed inside. + const { warmCanonicalCatalogueSearchCaches } = await import("@/lib/site-content/warm-catalogue-caches"); + void warmCanonicalCatalogueSearchCaches(); - // Build the setup status once at boot. Every page load asks for it, and the first build after a - // restart took ~9.6 s in production (0.6 s warm). The route keeps its cache on globalThis, so - // this fills the copy requests read. The host only needs to be non-loopback to pass the - // local-origin guard; nothing is fetched from it. Non-blocking; failures are swallowed. - const { GET: warmSetupStatus } = await import("@/app/api/setup-status/route"); - void warmSetupStatus(new Request("https://startup-warm.invalid/api/setup-status")).catch(() => undefined); + // Build the setup status once at boot. Every page load asks for it, and the first build after a + // restart took ~9.6 s in production (0.6 s warm). The route keeps its cache on globalThis, so + // this fills the copy requests read. The host only needs to be non-loopback to pass the + // local-origin guard; nothing is fetched from it. Non-blocking; failures are swallowed. + const { GET: warmSetupStatus } = await import("@/app/api/setup-status/route"); + void warmSetupStatus(new Request("https://startup-warm.invalid/api/setup-status")).catch(() => undefined); + + // My Day › Alerts: once a minute, send due Remind me notes and morning briefs (owner yes, 6 Oct + // 2026). Does nothing without phone-alert keys; every send is claimed in the database first, so + // several server instances never double-send. + const { startTimedAlertSender } = await import("@/lib/alerts/timed-sender"); + const { createAdminClient } = await import("@/lib/supabase/admin"); + startTimedAlertSender(createAdminClient); + } - // My Day › Alerts: once a minute, send due Remind me notes and morning briefs (owner yes, 6 Oct - // 2026). Does nothing without phone-alert keys; every send is claimed in the database first, so - // several server instances never double-send. - const { startTimedAlertSender } = await import("@/lib/alerts/timed-sender"); - const { createAdminClient } = await import("@/lib/supabase/admin"); - startTimedAlertSender(createAdminClient); + if (process.env.NEXT_RUNTIME === "edge") { + await import("./sentry.edge.config"); + } } export { captureRequestError as onRequestError }; diff --git a/src/lib/calculators/clinical-math/governance.json b/src/lib/calculators/clinical-math/governance.json new file mode 100644 index 0000000000..449de0e351 --- /dev/null +++ b/src/lib/calculators/clinical-math/governance.json @@ -0,0 +1,95 @@ +{ + "schemaVersion": 1, + "releaseStatus": "held", + "scope": "All bedside physiological outputs, including numerical results, dose suggestions, thresholds, and management guidance. Psychometric calculator fixtures are governed separately.", + "clinicalReview": { + "status": "pending", + "reviewer": null, + "reviewedOn": null, + "approvalEvidence": null + }, + "evidenceLimit": "Implementation inventory and engineering drift pins only. Source leads below are unverified implementation attributions, not reviewed primary-source correspondence, clinical validation, or release approval.", + "algorithms": [ + { + "id": "cockcroft-gault", + "implementation": "src/lib/calculators/clinical-math/renal.ts", + "exports": ["calculateCockcroftGault", "calculateIdealBodyWeightKg"], + "formula": "CrCl=(140-age)*weight*1.23/SCr for male or 1.04 for female (SCr umol/L); (140-age)*weight/(72*SCr)*(0.85 if female) for mg/dL. Devine IBW base 50/45.5 kg + 0.9*(heightCm-152.4); adjusted weight=IBW+0.4*(actual-IBW).", + "sourceLeads": [ + "Cockcroft-Gault and Devine formulas named in implementation; exact primary publications and claim correspondence not recorded.", + "AMH / Therapeutic Guidelines named in implementation; edition, page and correspondence not recorded." + ], + "reviewRequired": [ + "Unit conversion and sex coefficients", + "Adult population and invalid-input limits", + "Weight selection, rounding and application to renal drug dosing", + "Renal stages, warnings and lithium management note" + ] + }, + { + "id": "ckd-epi-2021", + "implementation": "src/lib/calculators/clinical-math/renal.ts", + "exports": ["calculateCkdEpi2021"], + "formula": "eGFR=142*min(SCr/kappa,1)^alpha*max(SCr/kappa,1)^-1.2*0.9938^age*(1.012 if female); kappa=0.7/0.9 and alpha=-0.241/-0.302 for female/male; SCr mg/dL, conversion umol/L /88.42.", + "sourceLeads": [ + "2021 CKD-EPI creatinine equation (race-free) named in implementation; exact primary publication and claim correspondence not recorded.", + "KDIGO / Australian guidelines named in implementation; edition and correspondence not recorded." + ], + "reviewRequired": [ + "Primary equation and conversion correspondence", + "Adults, indexing to 1.73m2 and exclusions", + "Stages and any dose-use implications" + ] + }, + { + "id": "qtc", + "implementation": "src/lib/calculators/clinical-math/qtc.ts", + "exports": ["calculateQtc"], + "formula": "RR seconds=60/HR; Bazett QTc=QT/sqrt(RR); Fridericia QTc=QT/cbrt(RR); Framingham QTc=QT+154*(1-RR); Hodges QTc=QT+1.75*(HR-60), QT/QTc milliseconds.", + "sourceLeads": [ + "Bazett, Fridericia, Framingham and Hodges equations named in implementation; primary papers, versions and correspondence not recorded." + ], + "reviewRequired": [ + "Primary formula sources and units", + "Heart-rate/sex selection, measurement population and invalid-input limits", + "Risk thresholds, kinetic cautions and medication/cardiology advice" + ] + }, + { + "id": "lithium-level", + "implementation": "src/lib/calculators/clinical-math/lithium.ts", + "exports": ["evaluateLithiumLevel"], + "formula": "Rule-based age/indication target ranges and level classifications; no independent clinical validation recorded.", + "sourceLeads": [ + "Therapeutic Guidelines, RANZCP monitoring standards and EXTRIP named in implementation; exact editions and claim correspondence not recorded." + ], + "reviewRequired": [ + "Target ranges, age and indication population", + "Sampling and steady-state restrictions", + "Toxicity thresholds, withholding/admission/dialysis and repeat-monitoring advice" + ] + }, + { + "id": "proportional-lithium-dose", + "implementation": "src/lib/calculators/clinical-math/lithium.ts", + "exports": ["validateLithiumDoseInput", "calculateProportionalLithiumDose"], + "formula": "Raw dose=current dose*(target level/current level); rounded dose=round(raw dose/increment)*increment. Additional input/ratio/dose bounds are implementation controls, not validated safe prescribing limits.", + "sourceLeads": [ + "Linear kinetics and Australian tablet increments asserted in implementation; dedicated primary source and prescribing validation not recorded." + ], + "reviewRequired": [ + "Dedicated dose-calculator clinical validation", + "Linear kinetics, sampling, steady state and patient exclusions", + "Tablet splitting/formulations, rounding, bounds and dose/monitoring advice" + ] + } + ], + "implementationPins": { + "src/lib/calculators/clinical-math/renal.ts": "51617e6db4aba04ce6ea89ad0830e21a5902d4a2be75b373821e05ae42cb42d7", + "src/lib/calculators/clinical-math/qtc.ts": "f34b5852a82759ef83fcd19863fb45ad62765953120c5d2a5ac490aede404bd4", + "src/lib/calculators/clinical-math/lithium.ts": "5319dedd327f3312c1ad0ada409a8f737c67e68a82199a76e032cdbbf37806ab", + "src/lib/calculators/clinical-math/types.ts": "2a3a846b4f5bb7a21ea915ebfa032c2eb95409d3c9ef34fa5fbc6a609c9004f3", + "src/lib/calculators/clinical-math/index.ts": "93ec21888c7292f2b4667ba85ccb48f5f8027f1617c3110285aa3d895e050a0d", + "src/components/calculators/clinical-math-panel.tsx": "62f5704c81b5d440ebf2662387f469c7dd92097277b668ee383a688718503efd" + } +} diff --git a/src/lib/cme/evidence-repository.ts b/src/lib/cme/evidence-repository.ts index f851d0d812..6d4408fdd0 100644 --- a/src/lib/cme/evidence-repository.ts +++ b/src/lib/cme/evidence-repository.ts @@ -78,8 +78,8 @@ export function evidenceFromRow(row: EvidenceRow) { /** * Removes a file at its owner's request. The database records when and why * (and blanks the stored file name, which may itself carry an identifier); - * the stored object is then deleted, or queued for cleanup if storage does - * not answer. Allowed in a closed year and on an archived entry: removal + * and queues the exact stored object for deletion in the same transaction. + * Immediate Storage removal is best effort; the durable janitor obligation remains. Allowed in a closed year and on an archived entry: removal * exists for privacy, not bookkeeping. */ export async function removeCmeEvidence( @@ -98,8 +98,8 @@ export async function removeCmeEvidence( .eq("id", evidenceId) .maybeSingle(); if (lookupError) throw new PublicApiError("Evidence could not be loaded. Try again.", 503); - if (!existing || existing.removed_at) throw new PublicApiError("Evidence not found.", 404); - const { data, error } = await client.rpc("cme_remove_evidence", { + if (!existing) throw new PublicApiError("Evidence not found.", 404); + const { data, error } = await client.rpc("cme_remove_evidence_v2", { p_owner_id: ownerId, p_evidence_id: evidenceId, p_reason: reason, @@ -110,13 +110,13 @@ export async function removeCmeEvidence( throw new PublicApiError("Say why the file is being removed (3 to 500 characters).", 400); throw new PublicApiError("The file could not be removed. Try again.", 503); } - // The record of removal is committed; the stored object goes next, or is - // queued for cleanup if storage does not answer. - if (!(await deleteOrQueueCmeEvidenceObject(client, ownerId, existing.storage_path))) - throw new PublicApiError( - "The file was removed from this activity, but its stored copy still needs deleting by an administrator.", - 503, - ); + // The RPC has already committed the cleanup obligation with the tombstone. A lost + // response, process exit or unavailable Storage must not strand the private object. + try { + await client.storage.from("cme-private-evidence").remove([existing.storage_path]); + } catch { + // The janitor retries the durable, owner-bound cleanup row. + } return evidenceFromRow(data as EvidenceRow); } diff --git a/src/lib/ingestion-recovery.ts b/src/lib/ingestion-recovery.ts index 7d31b53afd..6d9b6131f0 100644 --- a/src/lib/ingestion-recovery.ts +++ b/src/lib/ingestion-recovery.ts @@ -3,6 +3,8 @@ export type IngestionRecoveryJob = { document_id: string; status: string | null; locked_at?: string | null; + lease_token?: string | null; + lease_expires_at?: string | null; documents?: { status?: string | null; page_count?: number | null; @@ -24,7 +26,11 @@ function parseLockedAt(value: string | null | undefined) { } export function isStaleProcessingJob(job: IngestionRecoveryJob, now: Date, staleAfterMinutes: number) { - if (job.status !== "processing" || !job.locked_at) return false; + if (job.status !== "processing") return false; + if (job.lease_expires_at != null) { + const expiry = parseLockedAt(job.lease_expires_at); + return expiry !== null && expiry <= now.getTime(); + } const lockedAt = parseLockedAt(job.locked_at); if (lockedAt === null) return false; return lockedAt < now.getTime() - staleAfterMinutes * 60_000; @@ -32,13 +38,21 @@ export function isStaleProcessingJob(job: IngestionRecoveryJob, now: Date, stale export function isRecoverableProcessingJob(job: IngestionRecoveryJob, now: Date, staleAfterMinutes: number) { if (job.status !== "processing") return false; + if (job.lease_expires_at != null) { + const expiry = parseLockedAt(job.lease_expires_at); + return expiry !== null && expiry <= now.getTime(); + } const lockedAt = parseLockedAt(job.locked_at); if (lockedAt === null) return true; return lockedAt < now.getTime() - staleAfterMinutes * 60_000; } export function isFreshProcessingJob(job: IngestionRecoveryJob, now: Date, staleAfterMinutes: number) { - if (job.status !== "processing" || !job.locked_at) return false; + if (job.status !== "processing") return false; + if (job.lease_expires_at != null) { + const expiry = parseLockedAt(job.lease_expires_at); + return expiry === null || expiry > now.getTime(); + } const lockedAt = parseLockedAt(job.locked_at); if (lockedAt === null) return false; return lockedAt >= now.getTime() - staleAfterMinutes * 60_000; @@ -50,7 +64,6 @@ export function buildIngestionRecoveryPlan(args: { staleAfterMinutes: number; }) { const now = args.now ?? new Date(); - const resetDocuments = new Set(); const actions: IngestionRecoveryAction[] = []; const jobsByDocument = new Map(); @@ -95,8 +108,8 @@ export function buildIngestionRecoveryPlan(args: { for (const job of recoverableJobs) { if (job.id !== retryJob.id) actions.push({ action: "supersede", jobId: job.id, documentId }); } - resetDocuments.add(documentId); - actions.push({ action: "retry", jobId: retryJob.id, documentId, resetDocument: true }); + // Recovery preserves all generations; the next claim mints a fresh write generation. + actions.push({ action: "retry", jobId: retryJob.id, documentId, resetDocument: false }); } // Apply supersedes before retries. A retry flips a row to `pending`; if a redundant sibling for @@ -110,7 +123,8 @@ export function buildIngestionRecoveryPlan(args: { return { actions: orderedActions, - resetDocumentIds: Array.from(resetDocuments), + // Kept for callers that display the preview; recovery never deletes index artifacts. + resetDocumentIds: [] as string[], supersedeCount: orderedActions.filter((action) => action.action === "supersede").length, retryCount: orderedActions.filter((action) => action.action === "retry").length, }; @@ -125,6 +139,8 @@ export type IngestionRecoverySkipReason = export type IngestionRecoveryRowState = { jobStatus: string | null; jobLockedAt: string | null; + jobLeaseToken: string | null; + jobLeaseExpiresAt: string | null; documentStatus: string | null; documentOwnerId: string | null; }; @@ -153,9 +169,8 @@ function recoveryActionKey(action: IngestionRecoveryAction) { * `buildIngestionRecoveryPlan` runs against a point-in-time snapshot, and the operator then * spends unbounded wall-clock time reading the preview before confirming. In that window a job * can finish normally, a worker can re-claim it, or a document can become `indexed` — and the - * plan's retry branch is destructive (`reset_document_index` deletes every chunk, page, image and - * section for the document with no guard of its own). Applying a stale plan therefore destroys a - * freshly committed index or races a live worker. + * retrying a stale plan can steal a current worker's authority. The RPC revalidates this + * snapshot under the same authority/job locks as worker claims and preserves index artifacts. * * This re-derives the plan from the fresh rows and keeps only actions the queue still asks for, * carrying the observed row state forward so the caller can guard each write on it. @@ -192,6 +207,8 @@ export function reconcileIngestionRecoveryPlan(args: { expected: { jobStatus: job.status ?? null, jobLockedAt: job.locked_at ?? null, + jobLeaseToken: job.lease_token ?? null, + jobLeaseExpiresAt: job.lease_expires_at ?? null, documentStatus: job.documents?.status ?? null, documentOwnerId: job.documents?.owner_id ?? null, }, @@ -200,3 +217,36 @@ export function reconcileIngestionRecoveryPlan(args: { return { applicable, skipped }; } + +/** Shared by both operator entry points: every mutation uses the atomic SQL contract. */ +export async function applyIngestionRecoveryActions( + client: { + rpc( + name: "recover_ingestion_job", + args: { + p_job_id: string; + p_action: string; + p_expected: IngestionRecoveryRowState; + }, + ): PromiseLike<{ data: unknown; error: { message: string } | null }>; + }, + applicable: ReconciledIngestionRecoveryAction[], +) { + const results: Array<{ action: IngestionRecoveryAction; applied: boolean; reason: string | null }> = []; + for (const { action, expected } of applicable) { + const { data, error } = await client.rpc("recover_ingestion_job", { + p_job_id: action.jobId, + p_action: action.action, + p_expected: expected, + }); + if (error) throw new Error(error.message); + const outcome = data && typeof data === "object" && !Array.isArray(data) ? (data as Record) : null; + const applied = outcome?.ok === true; + results.push({ + action, + applied, + reason: applied ? null : typeof outcome?.reason === "string" ? outcome.reason : "invalid_recovery_response", + }); + } + return results; +} diff --git a/src/lib/logger.ts b/src/lib/logger.ts index a431ef0bd9..7e06b9bd31 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -16,7 +16,7 @@ const MAX_DEPTH = 5; // `answer_text` through. Over-redacting a `queryMode`-style label is the accepted // cost of a redaction layer that does not depend on call-site discipline. const SENSITIVE_KEY = - /authorization|cookie|token|secret|api[-_]?key|password|service[-_]?role|email|query|question|prompt|\bcontent\b|answer|patient|\bmrn\b/i; + /authorization|cookie|token|secret|api[-_]?key|password|service[-_]?role|email|query|question|prompt|\bcontent\b|answer|patient|\bmrn\b|file[-_]?name|storage[-_]?path|document[-_]?path/i; const SENSITIVE_VALUE_PATTERN = /\b(?:mrn|ur|unit\s*no\.?)\s*[:#]?\s*\d{6,10}\b|\b\d{3}\s\d{3}\s\d{4}\b/i; diff --git a/src/lib/observability/agent-monitoring.ts b/src/lib/observability/agent-monitoring.ts index dd8fb8852b..a75c003786 100644 --- a/src/lib/observability/agent-monitoring.ts +++ b/src/lib/observability/agent-monitoring.ts @@ -53,12 +53,17 @@ export function instrumentOpenAIClientForAgentMonitoring(clien * interaction UUID — never query text or any derived clinical identifier. */ export function setAgentConversationId(conversationId: string): void { + // Only UUID syntax crosses this boundary; callers must use the generated + // interaction UUID, never a patient/document identifier or clinical text. + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(conversationId)) return; if (!isSentryAgentMonitoringEnabled()) { return; } try { - Sentry.setConversationId(conversationId); + // The SDK's Node convenience export delegates to this public Scope API. + // Next's Edge entry omits that export but exposes the same isolation scope. + Sentry.getIsolationScope().setConversationId(conversationId); } catch { // Optional observability must never take down answer generation. } diff --git a/src/lib/open-shifts/mine.ts b/src/lib/open-shifts/mine.ts index 064be66fa4..e3f2f549be 100644 --- a/src/lib/open-shifts/mine.ts +++ b/src/lib/open-shifts/mine.ts @@ -1,7 +1,7 @@ import { fatigueWarnings, type FatigueShift } from "@/lib/roster/fatigue-rules"; import { FATIGUE_RULE_SET } from "@/lib/roster/fatigue-rules-source"; import { isWorkedKind } from "@/lib/roster/shift-kind"; -import { addDaysToDate, perthDateOf } from "@/lib/roster/shifts/perth-time"; +import { addDaysToDate, perthDateOf, perthWallToIso } from "@/lib/roster/shifts/perth-time"; import type { OpenShiftListing } from "./model"; @@ -55,7 +55,7 @@ function hoursInside(row: { startsAt: string; endsAt: string }, fromMs: number, return end > start ? (end - start) / HOUR_MS : 0; } -const perthMidnight = (date: string) => Date.parse(`${date}T00:00:00+08:00`); +const workMidnight = (date: string) => Date.parse(perthWallToIso(date, "00:00")!); /** * The busiest 14 consecutive days among the windows that start today or in @@ -87,8 +87,8 @@ export function hoursMeter( for (let offset = 0; offset < 14; offset += 1) { const from = addDaysToDate(today, offset); const to = addDaysToDate(from, 13); - const fromMs = perthMidnight(from); - const toMs = perthMidnight(addDaysToDate(to, 1)); + const fromMs = workMidnight(from); + const toMs = workMidnight(addDaysToDate(to, 1)); const sum = (rows: readonly { startsAt: string; endsAt: string }[]) => rows.reduce((total, row) => total + hoursInside(row, fromMs, toMs), 0); const r = sum(worked); diff --git a/src/lib/open-shifts/posted.ts b/src/lib/open-shifts/posted.ts index 0699933e32..f8bc6777c7 100644 --- a/src/lib/open-shifts/posted.ts +++ b/src/lib/open-shifts/posted.ts @@ -1,4 +1,4 @@ -import { addDaysToDate, perthDateOf } from "@/lib/roster/shifts/perth-time"; +import { addDaysToDate, perthDateOf, perthWallToIso } from "@/lib/roster/shifts/perth-time"; import type { RosterManageOpenShift } from "@/lib/roster/team/model"; type Row = Pick; @@ -17,7 +17,7 @@ const byStart = (a: Row, b: Row) => Date.parse(a.startsAt) - Date.parse(b.starts /** Upcoming posted shifts by what the poster has to do: decide, wait, or nothing. */ export function groupPosted(shifts: readonly T[], now: Date): PostedGroups { const upcoming = shifts.filter((row) => Date.parse(row.endsAt) > now.getTime()); - const weekEnd = Date.parse(`${addDaysToDate(perthDateOf(now), 7)}T00:00:00+08:00`); + const weekEnd = Date.parse(perthWallToIso(addDaysToDate(perthDateOf(now), 7), "00:00")!); return { hasRequest: upcoming.filter((row) => row.status === "claimed" || row.status === "reported").sort(byStart), open: upcoming.filter((row) => row.status === "open").sort(byStart), diff --git a/src/lib/privacy.ts b/src/lib/privacy.ts index 97ee993125..c7fef4c7e1 100644 --- a/src/lib/privacy.ts +++ b/src/lib/privacy.ts @@ -69,6 +69,60 @@ export function safeErrorLogDetails(error: unknown) { }; } +/** + * Diagnostics for upload failures and fatal alerts must not contain free text. + * A filename, person's name or provider response need not match a redaction + * pattern to be sensitive. Preserve only fixed error classes and known codes. + */ +export function contentFreeErrorDetails(error: unknown): { + errorType: string; + errorCode?: string; + status?: number; +} { + const errorTypes = new Set([ + "Error", + "TypeError", + "RangeError", + "SyntaxError", + "ReferenceError", + "URIError", + "EvalError", + "AbortError", + "TimeoutError", + "WorkerAbortError", + ]); + const errorCodes = new Set([ + "23502", + "23503", + "23505", + "42501", + "42703", + "42P01", + "57014", + "P0001", + "PGRST116", + "ECONNRESET", + "ECONNREFUSED", + "ETIMEDOUT", + ]); + try { + const record = error && typeof error === "object" ? (error as Record) : {}; + const errorType = typeof record.name === "string" && errorTypes.has(record.name) ? record.name : "UnknownError"; + const errorCode = typeof record.code === "string" && errorCodes.has(record.code) ? record.code : undefined; + const status = + typeof record.status === "number" && + Number.isInteger(record.status) && + record.status >= 100 && + record.status <= 599 + ? record.status + : undefined; + return { errorType, ...(errorCode ? { errorCode } : {}), ...(status ? { status } : {}) }; + } catch { + // Error-like objects can have throwing getters. Reporting must not prevent cleanup. + return { errorType: "UnknownError" }; + } +} + export function redactCaptionIdentifiers(value: string): string { const clinicalRangePattern = /^\d+(?:\.\d+)?\s*-\s*\d+(?:\.\d+)?(?:\s*[A-Za-zµ/%][\w/%.-]*)?$/i; return value diff --git a/src/lib/rag/rag-answer-coalescing.ts b/src/lib/rag/rag-answer-coalescing.ts new file mode 100644 index 0000000000..cdc9870e14 --- /dev/null +++ b/src/lib/rag/rag-answer-coalescing.ts @@ -0,0 +1,87 @@ +import { + answerInflight, + answerInflightCacheKey, + cloneAnswer, + withRagAnswerQueryPlanDiagnostics, +} from "@/lib/rag/rag-cache"; +import { + recordAnswerOrigination, + recordAnswerOriginationFinished, + recordCoalescedAnswerWaiter, +} from "@/lib/observability/answer-coalescing-metrics"; +import { awaitWithCallerSignal } from "@/lib/rag/rag-abort-signal"; +import { observeRagAnswer } from "@/lib/rag/rag-programme-telemetry"; +import type { RagObservationContext, SearchChunksArgs } from "@/lib/rag/rag-contracts"; +import type { RagAnswer } from "@/lib/types"; + +type CoalescingRequest = SearchChunksArgs & { + onProgress?: (event: { stage: "cached"; message: string; reason: string }) => void | Promise; + observationContext?: RagObservationContext; +}; + +/** Join only a current answer promise, or originate once without replacing another caller. */ +export async function answerWithCoalescing( + args: CoalescingRequest, + startedAt: number, + enabled: boolean, + originate: () => Promise, +): Promise { + let inflightKey = enabled ? await answerInflightCacheKey(args) : null; + let existing = inflightKey ? answerInflight.get(inflightKey) : undefined; + + while (existing) { + recordCoalescedAnswerWaiter(); + await args.onProgress?.({ + stage: "cached", + message: "Waiting for an identical cited answer request already in progress.", + reason: "answer_inflight_coalesced", + }); + try { + const sharedAnswer = await awaitWithCallerSignal(existing, args.signal); + const currentInflightKey = await answerInflightCacheKey(args); + if (currentInflightKey !== inflightKey) { + // Review committed while waiting (or freshness is unavailable). Originate + // once against current state; never deliver the old promise's evidence. + inflightKey = currentInflightKey; + break; + } + const answer = cloneAnswer(sharedAnswer); + answer.routingReason = answer.routingReason + ? `${answer.routingReason}; answer_inflight_coalesced` + : "answer_inflight_coalesced"; + answer.latencyTimings = { + ...answer.latencyTimings, + total_latency_ms: Date.now() - startedAt, + }; + return observeRagAnswer(answer, args.observationContext); + } catch { + if (args.signal?.aborted) { + throw args.signal.reason ?? new DOMException("The operation was aborted.", "AbortError"); + } + // An originating caller's abort/failure must not fail this connected waiter. + // Recheck first: another waiter may have installed a replacement while the + // rejected promise's microtasks drained. + const replacement = inflightKey ? answerInflight.get(inflightKey) : undefined; + if (replacement && replacement !== existing) { + existing = replacement; + continue; + } + break; + } + } + + // A stale waiter may reach a revision another caller already owns. Do not + // replace that caller's promise; this bounded fallback originates independently. + const originationKey = inflightKey && !answerInflight.has(inflightKey) ? inflightKey : null; + if (originationKey) recordAnswerOrigination(); + const pending = originate() + .then((answer) => withRagAnswerQueryPlanDiagnostics(answer, args)) + .finally(() => { + if (originationKey) { + if (answerInflight.get(originationKey) === pending) answerInflight.delete(originationKey); + recordAnswerOriginationFinished(); + } + }); + if (originationKey) answerInflight.set(originationKey, pending); + return observeRagAnswer(await pending, args.observationContext); +} diff --git a/src/lib/rag/rag-cache.ts b/src/lib/rag/rag-cache.ts index 5869430730..3aba92b193 100644 --- a/src/lib/rag/rag-cache.ts +++ b/src/lib/rag/rag-cache.ts @@ -5,7 +5,6 @@ import { sanitizeRagEvalDiagnostics } from "@/lib/rag/rag-eval-diagnostics"; import { createHash } from "node:crypto"; import { createAdminClient } from "@/lib/supabase/admin"; import { buildClinicalTextSearchQuery } from "@/lib/clinical-search"; -import { readExpiringCacheEntry, writeBoundedExpiringCacheEntry } from "@/lib/bounded-ttl-cache"; import { ragDeepMemoryVersion } from "@/lib/deep-memory"; import { env } from "@/lib/env"; import { hashQueryText, queryCacheKeyForStorage } from "@/lib/query-privacy"; @@ -15,7 +14,6 @@ import { retrievalAccessScopeForArgs, retrievalAccessScopeKey, type RetrievalAcc import { assertRagRequestContextIntegrity } from "@/lib/rag/rag-context-snapshot"; import { governedCorpusComponentState, sanitizeRagCandidateMatchCounts } from "@/lib/rag/rag-contracts"; import { sanitizeRagQueryPlanDiagnostics } from "@/lib/rag/rag-retrieval-variants"; -import { normalizeSourceMetadata } from "@/lib/source-metadata"; import { retrievalPlanForQueryClass, type RagRequestContext, @@ -52,32 +50,20 @@ const searchCache = new Map< // still writes unsafe review-fallback answers under v24. v25 re-issues the intentional // #ZK460W invalidation: rows written under v24 (and earlier) become unreachable. The bump // costs one cold cache and is the mechanism this constant exists for. -export const ragCacheDependencyVersion = "rag-cache-v25"; -const cacheIndexingVersionTtlMs = 5000; -const cacheIndexingVersionMaxEntries = 512; -const cacheIndexingVersionCache = new Map(); -/** - * The two corpus-staleness stamps that carry no information about the corpus. - * - * Audit L21: a PostgREST `error` on the `documents` stamp query used to be - * folded into INDEXING_STAMP_EMPTY_CORPUS, making a failed read indistinguishable - * from "nothing is indexed". Both constants are stable across requests, so two - * stamp failures bracketing a worker-side reindex (which cannot call this - * process's in-process invalidation) let a pre-reindex answer be served with - * `answer_cache_hit` — the exact staleness this stamp exists to prevent. An - * error now produces the distinct UNAVAILABLE stamp, and neither read nor write - * trusts it. - */ +// v26: governance-sensitive, complete-scope revisions replace the latest-indexed +// document stamp. Old rows cannot establish freshness in the new namespace. +export const ragCacheDependencyVersion = "rag-cache-v26"; +/** Failed revision reads never establish an empty corpus or permit cache reuse (L21). */ const INDEXING_STAMP_UNAVAILABLE = "index-stamp-unavailable"; -const INDEXING_STAMP_EMPTY_CORPUS = "no-indexed-documents"; +const SCOPE_REVISION_PATTERN = /^rag-cache-scope-v1:[0-9a-f]{32}$/; function isUnavailableIndexingStamp(indexingVersion: string) { return indexingVersion.endsWith(`:${INDEXING_STAMP_UNAVAILABLE}`); } /** - * Invalidation generations for deferred `setCachedAnswer` promotions. Review / - * table-fact mutations do not change the documents indexing stamp, so the - * mid-request staleness guard alone cannot catch them. Epochs are owner-scoped + * Invalidation generations additionally fence process-local reads/promotions + * against same-process mutations, including mutations outside documents. The + * database scope revision fences source reviews across replicas. Epochs are owner-scoped * so one tenant's invalidation cannot suppress another tenant's valid write; * a null/undefined ownerId bump (full clear) advances the global epoch. */ @@ -167,12 +153,6 @@ function normalizedCacheQuery(query: string) { return buildClinicalTextSearchQuery(query).toLowerCase().replace(/\s+/g, " ").trim(); } -function cacheIndexingVersionCacheKey( - args: Pick, -) { - return scopeKey(args); -} - function modeKey(args: Pick) { return args.queryMode ?? "auto"; } @@ -419,6 +399,15 @@ export function answerCacheLookupAllowedForRequest(args: AnswerCachePolicyArgs, ); } +/** In-flight reuse needs the same durable source revision as persisted reuse. */ +export async function answerInflightCacheKey(args: SearchChunksArgs): Promise { + if (!answerCoalescingAllowedForRequest(args)) return null; + const epoch = captureInvalidationEpoch(args.ownerId); + const revision = await cacheIndexingVersion(args, { forceRefresh: true }); + if (isUnavailableIndexingStamp(revision) || invalidationEpochChanged(args.ownerId, epoch)) return null; + return `${scopedAnswerCacheKey(args)}|revision:${revision}|epoch:${epoch.global}:${epoch.owner}`; +} + type RagPublicCacheKind = "search" | "answer"; type RagPublicCacheEvidence = Pick; export type RagPublicCacheWriteProof = Readonly<{ @@ -682,13 +671,18 @@ export async function getCachedAnswer( answerCache.delete(key); return null; } - const indexingVersion = options?.indexingVersionAtRequestStart ?? (await cacheIndexingVersion(args)); + const invalidationEpochAtStart = captureInvalidationEpoch(args.ownerId); + const indexingVersion = await cacheIndexingVersion(args, { forceRefresh: true }); + if (invalidationEpochChanged(args.ownerId, invalidationEpochAtStart)) return null; if (isUnavailableIndexingStamp(cached.indexingVersion)) { answerCache.delete(key); return null; } if (isUnavailableIndexingStamp(indexingVersion)) return null; - if (cached.indexingVersion !== indexingVersion) { + if ( + cached.indexingVersion !== indexingVersion || + (options?.indexingVersionAtRequestStart && options.indexingVersionAtRequestStart !== indexingVersion) + ) { answerCache.delete(key); return null; } @@ -714,7 +708,7 @@ export async function getCachedAnswer( * Callers may deliberately NOT await this — it is a cache write, never part of the * response contract. `answerQuestionWithScopeUncoalesced` defers it on a shared-cache * hit precisely so the fastest path in the system does not pay this function's - * `documents` round trip before responding (latency audit 2026-07-28, L1-1). Deferring + * scope-revision round trip before responding (latency audit 2026-07-28, L1-1). Deferring * is safe only while nothing mutates `answer` after the call: the clone below happens * after an `await`, so a caller that mutates it in the meantime would cache the mutation. * @@ -725,7 +719,7 @@ export async function getCachedAnswer( * * Deferred callers must also survive `invalidateRagCachesForOwner` during the await: * the owner-scoped invalidation epoch checks below discard a post-invalidation write - * even when the documents indexing stamp is unchanged (table-fact / review mutations), + * even for mutations outside the document revision (for example table facts), * including after the shared-cache row commit so a late invalidation cannot leave a * stale shared answer behind. * @@ -992,14 +986,19 @@ export async function getCachedSearch( searchCache.delete(key); return null; } - const indexingVersion = options?.indexingVersionAtRequestStart ?? (await cacheIndexingVersion(args)); + const invalidationEpochAtStart = captureInvalidationEpoch(args.ownerId); + const indexingVersion = await cacheIndexingVersion(args, { forceRefresh: true }); throwIfAborted(args.signal); + if (invalidationEpochChanged(args.ownerId, invalidationEpochAtStart)) return null; if (isUnavailableIndexingStamp(cached.indexingVersion)) { searchCache.delete(key); return null; } if (isUnavailableIndexingStamp(indexingVersion)) return null; - if (cached.indexingVersion !== indexingVersion) { + if ( + cached.indexingVersion !== indexingVersion || + (options?.indexingVersionAtRequestStart && options.indexingVersionAtRequestStart !== indexingVersion) + ) { searchCache.delete(key); return null; } @@ -1164,69 +1163,39 @@ function isGenerationFallbackAnswer(answer: Pick, options?: { forceRefresh?: boolean }, ) { + // Legacy callers may request a refresh; all reads now use the same fresh RPC. + void options; throwIfAborted(args.signal); - const cacheKey = cacheIndexingVersionCacheKey(args); - if (options?.forceRefresh) cacheIndexingVersionCache.delete(cacheKey); - const cached = readExpiringCacheEntry(cacheIndexingVersionCache, cacheKey); - if (cached) return cached.value; - - let value = `${ragDeepMemoryVersion}:${INDEXING_STAMP_UNAVAILABLE}`; + const unavailable = `${ragDeepMemoryVersion}:${INDEXING_STAMP_UNAVAILABLE}`; try { - const supabase = createAdminClient(); - const documentFilters = args.documentIds?.length ? args.documentIds : args.documentId ? [args.documentId] : null; - let query = supabase - .from("documents") - .select("id,updated_at,metadata") - .eq("status", "indexed") - .order("updated_at", { ascending: false }) - .limit(1); const accessScope = retrievalAccessScopeForArgs(args); - if (accessScope.ownerId && accessScope.includePublic) { - query = query.or(`owner_id.eq.${accessScope.ownerId},owner_id.is.null`); - } else if (accessScope.ownerId) { - query = query.eq("owner_id", accessScope.ownerId); - } else { - query = query.is("owner_id", null); - } - if (documentFilters?.length) query = query.in("id", documentFilters); + const documentIds = args.documentIds?.length ? args.documentIds : args.documentId ? [args.documentId] : null; + let query = createAdminClient().rpc("rag_cache_scope_revision_v1", { + p_owner_id: accessScope.ownerId ?? null, + p_include_public: accessScope.includePublic, + p_document_ids: documentIds ? [...new Set(documentIds)].sort() : null, + }); if (args.signal) query = query.abortSignal(args.signal); const { data, error } = await query; throwIfAborted(args.signal); - if (error) { - // A failed read is NOT an empty corpus (audit L21). - value = `${ragDeepMemoryVersion}:${INDEXING_STAMP_UNAVAILABLE}`; - } else if (!data?.length) { - value = `${ragDeepMemoryVersion}:${INDEXING_STAMP_EMPTY_CORPUS}`; - } else { - const latest = data[0] as { id?: string; updated_at?: string | null; metadata?: unknown }; - const metadata = normalizeSourceMetadata(latest.metadata); - const indexedAt = metadata.indexed_at ?? latest.updated_at ?? "unknown"; - const generationId = - latest.metadata && typeof latest.metadata === "object" && "index_generation_id" in latest.metadata - ? String((latest.metadata as { index_generation_id?: unknown }).index_generation_id ?? "") - : ""; - value = `${ragDeepMemoryVersion}:${latest.id ?? "all"}:${indexedAt}:${generationId}`; - } + return !error && typeof data === "string" && data.length === 51 && SCOPE_REVISION_PATTERN.test(data) + ? `${ragDeepMemoryVersion}:${data}` + : unavailable; } catch { if (args.signal?.aborted) throw abortReason(args.signal); - value = `${ragDeepMemoryVersion}:${INDEXING_STAMP_UNAVAILABLE}`; - } - throwIfAborted(args.signal); - if (isUnavailableIndexingStamp(value)) { - cacheIndexingVersionCache.delete(cacheKey); - return value; + return unavailable; } - writeBoundedExpiringCacheEntry( - cacheIndexingVersionCache, - cacheKey, - { value, expiresAt: Date.now() + cacheIndexingVersionTtlMs }, - cacheIndexingVersionMaxEntries, - ); - return value; } export async function getSharedCachedSearch( @@ -1243,8 +1212,14 @@ export async function getSharedCachedSearch( if (args.ragQueryPlanMode === "shadow" && !isIssuedRagShadowControl(args)) return null; if (args.skipCache || env.RAG_SEARCH_CACHE_TTL_MS <= 0 || !isRagCacheAccessAllowed(args)) return null; const normalizedQuery = retrievalPlanCacheQuery(args, queryClass, queryVariants); - const indexingVersion = options?.indexingVersionAtRequestStart ?? (await cacheIndexingVersion(args)); + const invalidationEpochAtStart = captureInvalidationEpoch(args.ownerId); + const indexingVersion = await cacheIndexingVersion(args, { forceRefresh: true }); if (isUnavailableIndexingStamp(indexingVersion)) return { kind: "miss", reason: "cache_lookup_error" }; + if ( + invalidationEpochChanged(args.ownerId, invalidationEpochAtStart) || + (options?.indexingVersionAtRequestStart && options.indexingVersionAtRequestStart !== indexingVersion) + ) + return { kind: "miss", reason: "unknown_filter_miss" }; try { let query = sharedCacheSelector(createAdminClient(), "search", args, indexingVersion, normalizedQuery); if (args.signal) query = query.abortSignal(args.signal); @@ -1259,6 +1234,14 @@ export async function getSharedCachedSearch( if (!Array.isArray(payload.results)) { return { kind: "miss", reason: "cache_payload_invalid" }; } + // A review may commit while the shared row is being read. A late old write + // or failed deletion is harmless only when the row's revision is still current. + const revisionAfterRead = await cacheIndexingVersion(args, { forceRefresh: true }); + throwIfAborted(args.signal); + if (isUnavailableIndexingStamp(revisionAfterRead)) return { kind: "miss", reason: "cache_lookup_error" }; + if (indexingVersion !== revisionAfterRead || invalidationEpochChanged(args.ownerId, invalidationEpochAtStart)) { + return { kind: "miss", reason: "unknown_filter_miss" }; + } return { kind: "hit", results: cloneSearchResults(payload.results), @@ -1359,8 +1342,14 @@ export async function getSharedCachedAnswer( ) return null; try { - const indexingVersion = options?.indexingVersionAtRequestStart ?? (await cacheIndexingVersion(args)); + const invalidationEpochAtStart = captureInvalidationEpoch(args.ownerId); + const indexingVersion = await cacheIndexingVersion(args, { forceRefresh: true }); if (isUnavailableIndexingStamp(indexingVersion)) return null; + if ( + invalidationEpochChanged(args.ownerId, invalidationEpochAtStart) || + (options?.indexingVersionAtRequestStart && options.indexingVersionAtRequestStart !== indexingVersion) + ) + return null; const { data, error } = await sharedCacheSelector( createAdminClient(), "answer", @@ -1370,6 +1359,11 @@ export async function getSharedCachedAnswer( ).maybeSingle(); if (error || !data?.payload) return null; throwIfAborted(args.signal); + const revisionAfterRead = await cacheIndexingVersion(args, { forceRefresh: true }); + throwIfAborted(args.signal); + if (indexingVersion !== revisionAfterRead || invalidationEpochChanged(args.ownerId, invalidationEpochAtStart)) { + return null; + } const payload = data.payload as { answer: RagAnswer; queryPlanDiagnostics?: unknown }; const answer = markAnswerQueryPlanDiagnostics( cloneAnswer(payload.answer), @@ -1433,7 +1427,8 @@ async function replaceLegacySharedCacheRow( .eq("dependency_version", ragCacheDependencyVersion); deleteQuery = ownerId ? deleteQuery.eq("owner_id", ownerId) : deleteQuery.is("owner_id", null); if (args.signal) deleteQuery = deleteQuery.abortSignal(args.signal); - await deleteQuery; + const { error: deleteError } = await deleteQuery; + if (deleteError) return; if (args.signal?.aborted) return; let insertQuery = supabase.from("rag_response_cache").insert({ owner_id: ownerId ?? null, @@ -1473,7 +1468,8 @@ async function replaceSharedCacheRow( .eq("dependency_version", identity.dependencyVersion); deleteQuery = identity.ownerId ? deleteQuery.eq("owner_id", identity.ownerId) : deleteQuery.is("owner_id", null); if (signal) deleteQuery = deleteQuery.abortSignal(signal); - await deleteQuery; + const { error: deleteError } = await deleteQuery; + if (deleteError) return; if (signal?.aborted) return; let insertQuery = supabase.from("rag_response_cache").insert({ owner_id: identity.ownerId, @@ -1615,9 +1611,10 @@ async function deleteSharedCacheRowByIdentity(identity: SharedCacheRowIdentity) .eq("indexing_version", identity.indexingVersion) .eq("dependency_version", identity.dependencyVersion); deleteQuery = identity.ownerId ? deleteQuery.eq("owner_id", identity.ownerId) : deleteQuery.is("owner_id", null); - await deleteQuery; - } catch (error) { - console.warn("Shared answer cache post-invalidation cleanup failed:", error); + const { error } = await deleteQuery; + if (error) console.warn("Shared answer cache post-invalidation cleanup failed."); + } catch { + console.warn("Shared answer cache post-invalidation cleanup failed."); } } @@ -1651,10 +1648,11 @@ async function deleteSharedCachedAnswerRow( .eq("indexing_version", indexingVersion) .eq("dependency_version", ragCacheDependencyVersion); deleteQuery = args.ownerId ? deleteQuery.eq("owner_id", args.ownerId) : deleteQuery.is("owner_id", null); - await deleteQuery; - } catch (error) { + const { error } = await deleteQuery; + if (error) console.warn("Shared answer cache post-invalidation cleanup failed."); + } catch { // Shared cache cleanup after a raced invalidation is best effort. - console.warn("Shared answer cache post-invalidation cleanup failed:", error); + console.warn("Shared answer cache post-invalidation cleanup failed."); } } @@ -1666,13 +1664,16 @@ export function invalidateRagCachesForOwner(ownerId?: string | null) { answerCache.clear(); answerInflight.clear(); searchCache.clear(); - cacheIndexingVersionCache.clear(); void (async () => { try { - await createAdminClient().from("rag_response_cache").delete().in("cache_kind", ["search", "answer"]); - } catch (error) { + const { error } = await createAdminClient() + .from("rag_response_cache") + .delete() + .in("cache_kind", ["search", "answer"]); + if (error) console.warn("Shared cache invalidation failed (all kinds)."); + } catch { // Shared cache invalidation is best effort. - console.warn("Shared cache invalidation failed (all kinds):", error); + console.warn("Shared cache invalidation failed (all kinds)."); } })(); return; @@ -1693,19 +1694,17 @@ export function invalidateRagCachesForOwner(ownerId?: string | null) { if (ragCacheKeyMatchesOwner(key, ownerId) || key.includes(siteAwareAnswerOwnerToken(ownerId))) searchCache.delete(key); } - for (const key of cacheIndexingVersionCache.keys()) { - if (ragCacheKeyMatchesOwner(key, ownerId)) cacheIndexingVersionCache.delete(key); - } void (async () => { try { const deleteQuery = createAdminClient().from("rag_response_cache").delete(); const scopedQuery = sharedCacheOwnerId ? deleteQuery.eq("owner_id", sharedCacheOwnerId) : deleteQuery.is("owner_id", null); - await scopedQuery.in("cache_kind", ["search", "answer"]); - } catch (error) { + const { error } = await scopedQuery.in("cache_kind", ["search", "answer"]); + if (error) console.warn("Shared cache invalidation failed for owner."); + } catch { // Shared cache invalidation is best effort. - console.warn("Shared cache invalidation failed for owner:", error); + console.warn("Shared cache invalidation failed for owner."); } })(); } @@ -1713,14 +1712,15 @@ export function invalidateRagCachesForOwner(ownerId?: string | null) { function invalidateAnonymousSharedRagCaches() { void (async () => { try { - await createAdminClient() + const { error } = await createAdminClient() .from("rag_response_cache") .delete() .is("owner_id", null) .in("cache_kind", ["search", "answer"]); - } catch (error) { + if (error) console.warn("Shared cache invalidation failed for anonymous."); + } catch { // Shared cache invalidation is best effort. - console.warn("Shared cache invalidation failed for anonymous:", error); + console.warn("Shared cache invalidation failed for anonymous."); } })(); } diff --git a/src/lib/rag/rag.ts b/src/lib/rag/rag.ts index 482819452a..ae274646dc 100644 --- a/src/lib/rag/rag.ts +++ b/src/lib/rag/rag.ts @@ -167,20 +167,16 @@ import { answerCacheAllowedForSourcePolicyConflicts, answerCacheLookupAllowedForRequest, answerCoalescingAllowedForRequest, - answerInflight, cacheIndexingVersion, - cloneAnswer, getCachedAnswer, getCachedSearch, getSharedCachedAnswer, getSharedCachedSearch, isSearchCacheEnabled, isSearchCacheLookupEnabled, - scopedAnswerCacheKey, setCachedAnswer, setCachedSearch, restoreRagAnswerQueryPlanArgs, - withRagAnswerQueryPlanDiagnostics, } from "@/lib/rag/rag-cache"; export { invalidateRagCachesForDocumentMutation, @@ -198,11 +194,6 @@ import { } from "@/lib/rag/rag-context-pack"; export { packedContextCacheKey } from "@/lib/rag/rag-context-pack"; import { classifySearchCacheOutcome, recordCacheLookup } from "@/lib/observability/cache-metrics"; -import { - recordAnswerOrigination, - recordAnswerOriginationFinished, - recordCoalescedAnswerWaiter, -} from "@/lib/observability/answer-coalescing-metrics"; import { buildRagSourceBlock, neutralizeIdentityField, @@ -284,7 +275,7 @@ export { analyzeQueryWithClassifierFallback, resetClassifierVerdictMemoForTests, } from "@/lib/rag/rag-classifier-fallback"; -import { awaitWithCallerSignal } from "@/lib/rag/rag-abort-signal"; +import { answerWithCoalescing } from "@/lib/rag/rag-answer-coalescing"; import { hasAdmissionCommunityLookupIntent, hasAdmissionCommunityTitleSupport, @@ -2130,51 +2121,9 @@ export async function answerQuestionWithScope(args: AnswerQuestionWithScopeArgs) !isAnswerPilotBudgetActive() && !args.loadRegisteredPublishedOriginalSectionLayout && answerCoalescingAllowedForRequest(args); - const inflightKey = coalescingEnabled ? scopedAnswerCacheKey(args) : null; - let existing = inflightKey ? answerInflight.get(inflightKey) : undefined; - - while (existing) { - recordCoalescedAnswerWaiter(); - await args.onProgress?.({ - stage: "cached", - message: "Waiting for an identical cited answer request already in progress.", - reason: "answer_inflight_coalesced", - }); - try { - const answer = cloneAnswer(await awaitWithCallerSignal(existing, args.signal)); - answer.routingReason = answer.routingReason - ? `${answer.routingReason}; answer_inflight_coalesced` - : "answer_inflight_coalesced"; - answer.latencyTimings = { - ...answer.latencyTimings, - total_latency_ms: Date.now() - startedAt, - }; - return observeRagAnswer(answer, args.observationContext); - } catch { - throwIfAborted(args.signal); - // An originating caller's abort/failure must not fail this connected waiter. - // Recheck first: another waiter may have installed a replacement while the - // rejected promise's microtasks drained. - const replacement = inflightKey ? answerInflight.get(inflightKey) : undefined; - if (replacement && replacement !== existing) { - existing = replacement; - continue; - } - break; - } - } - - if (inflightKey) recordAnswerOrigination(); - const pending = answerQuestionWithScopeUncoalesced(args, startedAt) - .then((answer) => withRagAnswerQueryPlanDiagnostics(answer, args)) - .finally(() => { - if (inflightKey) { - answerInflight.delete(inflightKey); - recordAnswerOriginationFinished(); - } - }); - if (inflightKey) answerInflight.set(inflightKey, pending); - return observeRagAnswer(await pending, args.observationContext); + return answerWithCoalescing(args, startedAt, coalescingEnabled, () => + answerQuestionWithScopeUncoalesced(args, startedAt), + ); } async function answerQuestionWithScopeUncoalesced( args: AnswerQuestionWithScopeArgs, diff --git a/src/lib/site-content/adapters/index.ts b/src/lib/site-content/adapters/index.ts index 332e412078..9fb509a95f 100644 --- a/src/lib/site-content/adapters/index.ts +++ b/src/lib/site-content/adapters/index.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { calculators } from "@/lib/calculators/calculator-fixtures"; -import { factsheets } from "@/lib/factsheets-data"; +import { FACTSHEET_DEMO_NOTICE, factsheets } from "@/lib/factsheets-data"; import { THERAPY_CATALOGUE_ASSETS } from "@/data/therapy-catalogue-assets"; import type { Therapy } from "@/lib/therapy-compass-types"; import { dictionaryEntries, dictionarySource } from "@/lib/dictionary-data"; @@ -252,7 +252,13 @@ function buildFactsheetRecords() { domain: "factsheets", route: producer.routeBuilder(sheet.slug), title: sheet.title, - body: [sheet.brand, `Audience: ${sheet.audience}`, sheet.summary, `Review date: ${sheet.reviewedOn}`] + body: [ + sheet.brand, + `Audience: ${sheet.audience}`, + sheet.summary, + FACTSHEET_DEMO_NOTICE, + `Content month: ${sheet.reviewedOn}`, + ] .filter(Boolean) .join("\n"), sourceRole: "clinical_reference", @@ -261,6 +267,10 @@ function buildFactsheetRecords() { sourceStatus: "current", sourceLineage: [ ...repositoryLineage("src/components/factsheets/factsheets-data.ts", sheet), + ...repositoryLineage("src/lib/factsheets-data.ts:summary-context", { + notice: FACTSHEET_DEMO_NOTICE, + dateMeaning: "content-month", + }), ...sheet.sources.map((source) => ({ sourceId: `reference:${source.org}:${source.n}`, sourceHash: siteContentValueHash(source), diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts index d8a9a20d2a..9c01f9951a 100644 --- a/src/lib/supabase/database.types.ts +++ b/src/lib/supabase/database.types.ts @@ -2216,6 +2216,12 @@ export type Database = { enrichment_status: string; id: string; last_error: string | null; + lease_authority_digest: string | null; + lease_duration_seconds: number | null; + lease_expected_generation_id: string | null; + lease_expires_at: string | null; + lease_token: string | null; + lease_write_generation_id: string | null; locked_at: string | null; locked_by: string | null; max_attempts: number; @@ -2232,6 +2238,12 @@ export type Database = { enrichment_status?: string; id?: string; last_error?: string | null; + lease_authority_digest?: string | null; + lease_duration_seconds?: number | null; + lease_expected_generation_id?: string | null; + lease_expires_at?: string | null; + lease_token?: string | null; + lease_write_generation_id?: string | null; locked_at?: string | null; locked_by?: string | null; max_attempts?: number; @@ -2248,6 +2260,12 @@ export type Database = { enrichment_status?: string; id?: string; last_error?: string | null; + lease_authority_digest?: string | null; + lease_duration_seconds?: number | null; + lease_expected_generation_id?: string | null; + lease_expires_at?: string | null; + lease_token?: string | null; + lease_write_generation_id?: string | null; locked_at?: string | null; locked_by?: string | null; max_attempts?: number; @@ -2346,10 +2364,16 @@ export type Database = { document_id: string; error_message: string | null; id: string; + lease_authority_digest: string | null; + lease_duration_seconds: number | null; + lease_expected_generation_id: string | null; + lease_expires_at: string | null; + lease_token: string | null; + lease_write_generation_id: string | null; locked_at: string | null; locked_by: string | null; max_attempts: number; - next_run_at: string; + next_run_at: string | null; progress: number; stage: string; started_at: string | null; @@ -2364,10 +2388,16 @@ export type Database = { document_id: string; error_message?: string | null; id?: string; + lease_authority_digest?: string | null; + lease_duration_seconds?: number | null; + lease_expected_generation_id?: string | null; + lease_expires_at?: string | null; + lease_token?: string | null; + lease_write_generation_id?: string | null; locked_at?: string | null; locked_by?: string | null; max_attempts?: number; - next_run_at?: string; + next_run_at?: string | null; progress?: number; stage?: string; started_at?: string | null; @@ -2382,10 +2412,16 @@ export type Database = { document_id?: string; error_message?: string | null; id?: string; + lease_authority_digest?: string | null; + lease_duration_seconds?: number | null; + lease_expected_generation_id?: string | null; + lease_expires_at?: string | null; + lease_token?: string | null; + lease_write_generation_id?: string | null; locked_at?: string | null; locked_by?: string | null; max_attempts?: number; - next_run_at?: string; + next_run_at?: string | null; progress?: number; stage?: string; started_at?: string | null; @@ -3708,6 +3744,7 @@ export type Database = { storage_cleanup_jobs: { Row: { attempts: number; + cme_evidence_id: string | null; completed_at: string | null; created_at: string; document_bucket: string; @@ -3733,6 +3770,7 @@ export type Database = { }; Insert: { attempts?: number; + cme_evidence_id?: string | null; completed_at?: string | null; created_at?: string; document_bucket?: string; @@ -3758,6 +3796,7 @@ export type Database = { }; Update: { attempts?: number; + cme_evidence_id?: string | null; completed_at?: string | null; created_at?: string; document_bucket?: string; @@ -3782,6 +3821,13 @@ export type Database = { updated_at?: string; }; Relationships: [ + { + foreignKeyName: "storage_cleanup_jobs_cme_evidence_id_fkey"; + columns: ["cme_evidence_id"]; + isOneToOne: true; + referencedRelation: "cme_evidence"; + referencedColumns: ["id"]; + }, { foreignKeyName: "storage_cleanup_jobs_public_source_reservation_id_fkey"; columns: ["public_source_reservation_id"]; @@ -5033,6 +5079,22 @@ export type Database = { cme_set_entry_goal: { Args: { p_owner_id: string; p_entry_id: string; p_goal_id: string | null }; Returns: Json }; cme_set_entry_archived: { Args: { p_owner_id: string; p_entry_id: string; p_archived: boolean }; Returns: Json }; cme_evidence_counts: { Args: { p_owner_id: string; p_year: number }; Returns: Json }; + work_move_team_v1: { + Args: { p_actor_id: string; p_service_id: string; p_expected_hospital_id: string; p_to_hospital_id: string }; + Returns: string; + }; + recover_ingestion_job: { + Args: { p_job_id: string; p_action: string; p_expected: Json }; + Returns: Json; + }; + rag_cache_scope_revision_v1: { + Args: { p_owner_id: string | null; p_include_public: boolean; p_document_ids?: string[] | null }; + Returns: string; + }; + cme_remove_evidence_v2: { + Args: { p_owner_id: string; p_evidence_id: string; p_reason: string }; + Returns: Database["public"]["Tables"]["cme_evidence"]["Row"]; + }; cme_remove_evidence: { Args: { p_owner_id: string; p_evidence_id: string; p_reason: string }; Returns: Database["public"]["Tables"]["cme_evidence"]["Row"]; diff --git a/src/lib/trusted-document-admission.ts b/src/lib/trusted-document-admission.ts index 678829b33e..39efdd5c70 100644 --- a/src/lib/trusted-document-admission.ts +++ b/src/lib/trusted-document-admission.ts @@ -345,14 +345,22 @@ export async function uploadTrustedDocumentBytes( return Object.freeze({ storagePath: recoveryPath, collisionRecovered: true }); } +type SupersededObjectReleaseFailure = Readonly<{ + stage: "remove" | "queue" | "unknown"; + code: "provider_error" | "database_error" | "request_failed" | "unknown"; +}>; + type SupersededObjectRelease = Readonly<{ outcome: "skipped" | "removed" | "queued" | "unrecorded"; - failures: readonly string[]; + failures: readonly SupersededObjectReleaseFailure[]; }>; -function releaseFailureMessage(step: string, error: unknown): string { - const message = error instanceof Error ? error.message : isRecord(error) ? String(error.message ?? "") : ""; - return `${step}: ${message.slice(0, 200) || "unknown error"}`; +function releaseFailureCode(error: unknown): SupersededObjectReleaseFailure["code"] { + const code = isRecord(error) && typeof error.code === "string" ? error.code : ""; + if (["ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "AbortError"].includes(code)) return "request_failed"; + if (/^23|^42|^57|^PGRST/.test(code)) return "database_error"; + if (error instanceof Error) return "provider_error"; + return "unknown"; } /** @@ -374,20 +382,20 @@ export async function releaseSupersededDocumentObject( const supersededPath = assertTrustedDocumentStoragePath(args.ownerId, args.documentId, args.supersededPath); const committedPath = assertTrustedDocumentStoragePath(args.ownerId, args.documentId, args.committedPath); if (supersededPath === committedPath) return { outcome: "skipped", failures: [] }; - const failures: string[] = []; + const failures: SupersededObjectReleaseFailure[] = []; try { const { error } = await args.remove([supersededPath]); if (!error) return { outcome: "removed", failures }; - failures.push(releaseFailureMessage("remove", error)); + failures.push({ stage: "remove", code: releaseFailureCode(error) }); } catch (error) { - failures.push(releaseFailureMessage("remove", error)); + failures.push({ stage: "remove", code: releaseFailureCode(error) }); } try { const { error } = await args.queueCleanup(supersededPath); if (!error) return { outcome: "queued", failures }; - failures.push(releaseFailureMessage("queue", error)); + failures.push({ stage: "queue", code: releaseFailureCode(error) }); } catch (error) { - failures.push(releaseFailureMessage("queue", error)); + failures.push({ stage: "queue", code: releaseFailureCode(error) }); } return { outcome: "unrecorded", failures }; } diff --git a/src/lib/work-roles/people.ts b/src/lib/work-roles/people.ts index ad1431e215..13d54d9767 100644 --- a/src/lib/work-roles/people.ts +++ b/src/lib/work-roles/people.ts @@ -189,8 +189,7 @@ export async function readPeople( const roleRows = check(managers); const grantRows = check(grants); - // A whole-team supervisor whose team has moved away (the move could not remove them) stays filed - // under the hospital that gave the role, so name that team too. + // Legacy grants may remain filed under a hospital a team has left; name that team too. const serviceNames = new Map(serviceRows.map((row) => [row.id, row.name])); const elsewhere = [ ...new Set( @@ -434,7 +433,7 @@ function writeFailed(error: { code?: string | null }): PublicApiError { * grant is filed under the old hospital but `work_can` matches it by team alone, so it would keep * working at the new hospital where nobody there could see or remove it. Supervisors of named * trainees are left alone, because they are scoped to the old hospital and simply stop matching. - * There is no transaction, so the move goes first and a failed removal is reported, never hidden. + * The database trigger revokes whole-team supervisors in the same transaction as the move. * Answers with the hospital the team left. */ async function moveTeam( @@ -459,34 +458,22 @@ async function moveTeam( await client.from("work_hospitals").select("id").eq("id", change.toHospitalId).is("archived_at", null).limit(1), ); if (!target.length) throw hospitalUnavailable(); - // Only while it is still where it was read, so two moves at once cannot both land. - const { data, error } = await client - .from("work_hospital_teams") - .update({ hospital_id: change.toHospitalId, linked_by: context.userId, linked_at: new Date().toISOString() }) - .eq("service_id", change.serviceId) - .eq("hospital_id", from) - .select("service_id"); - if (error?.code === "23503") throw hospitalUnavailable(); - if (error) throw writeFailed(error); - if (!data?.length) { + // The versioned RPC requires atomic move/revocation SQL before any mutation. + const { data, error } = await client.rpc("work_move_team_v1", { + p_actor_id: context.userId, + p_service_id: change.serviceId, + p_expected_hospital_id: from, + p_to_hospital_id: change.toHospitalId, + }); + if (error?.message.includes("work_role_denied")) throw denied(); + if (error?.code === "23503" || error?.message.includes("work_hospital_unavailable")) throw hospitalUnavailable(); + if (error?.message.includes("work_team_changed")) { throw new PublicApiError("That team changed while you were moving it. Try again.", 409, { code: "work_team_changed", }); } - const { error: supervisorError } = await client - .from("work_role_grants") - .update({ revoked_at: new Date().toISOString(), revoked_by: context.userId }) - .eq("role", "supervisor") - .eq("service_id", change.serviceId) - .is("subject_user_id", null) - .is("revoked_at", null); - if (supervisorError) { - throw new PublicApiError( - "The team moved, but its whole-team supervisors weren't removed. Remove them under the hospital it left.", - 503, - { code: "work_team_supervisors_left" }, - ); - } + if (error) throw writeFailed(error); + if (data !== from) throw unavailable(); return from; } diff --git a/supabase/applied-migration-hashes.json b/supabase/applied-migration-hashes.json index 870bd6b62f..cc0e669797 100644 --- a/supabase/applied-migration-hashes.json +++ b/supabase/applied-migration-hashes.json @@ -1,7 +1,7 @@ { "note": "sha256 of every migration this repository has shipped, with newlines normalised. An entry whose hash changes means an applied migration was edited, which cannot reach the live database. See scripts/check-migration-immutability.mjs.", "generator": "scripts/check-migration-immutability.mjs", - "sealed_at": "2026-10-18T06:29:14.181Z", + "sealed_at": "2026-10-18T08:41:00.862Z", "versions": { "20260527000000_bulk_ingestion.sql": "813e8e314064a6dcbaaa55e2765a8ce901d5783b304b4a7af7bb7417a07daf7c", "20260527001000_documents_policy_cleanup.sql": "559c26f3172bf75e545a41adef75538b2bf648ea7d53c8eb3d3ea7c244c65a48", @@ -323,6 +323,7 @@ "20261017120000_teaching_display_revoke_null_creator_guard.sql": "3b92e22983206cbaa8950c07a6b3e6910b3701fa9c49a9f9e5504c232b4397e4", "20261017175448_scope_ingestion_claim_atomically.sql": "af192755dd24aa8a6e6146013e1b7abd1b2954a53410c301dedfcb7576c88eba", "20261018053558_recheck_ingestion_claim_scope_after_job_lock.sql": "a427b8aacce70579ad26bd701adb65912a345ab78d54e5b34386bbd05d261b6c", - "20261018062736_recheck_legacy_ingestion_claim_document_after_job_lock.sql": "39f08a91ed4c15a8aff0dff80341242604101886de873c5cadafa65e9b92e3c8" + "20261018062736_recheck_legacy_ingestion_claim_document_after_job_lock.sql": "39f08a91ed4c15a8aff0dff80341242604101886de873c5cadafa65e9b92e3c8", + "20261018082910_audit_durable_recovery_pairing_and_cache_fences.sql": "f0667fed9f0294c1f5ba40252d11ee66e6de07be340a1ec789e0503a1192f2ac" } } diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index 679b2eff60..fb59a19c60 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -2,7 +2,7 @@ "generated_at": "2026-10-18T06:37:18.983Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83", - "schema_sha256": "58e7d9c92b1264426f6cd239fdc136f978afe90770e31f950a0b4c505628e4fb", + "schema_sha256": "ddba5299f794d7be628fe0e9a85a4f7c4c4218bd29854c9e209a25a3c24909e5", "replay_seconds": 14, "snapshot": { "views": [ @@ -11458,6 +11458,14 @@ "not_null": true, "generated": "" }, + { + "name": "cme_evidence_id", + "type": "uuid", + "default": null, + "identity": "", + "not_null": false, + "generated": "" + }, { "name": "completed_at", "type": "timestamp with time zone", @@ -16441,6 +16449,12 @@ "table": "source_review_events", "def_hash": "616d06e4b7bca60c216c7c1a86d5e02f" }, + { + "def": "CREATE UNIQUE INDEX storage_cleanup_jobs_cme_evidence_key ON public.storage_cleanup_jobs USING btree (cme_evidence_id) WHERE (cme_evidence_id IS NOT NULL)", + "name": "storage_cleanup_jobs_cme_evidence_key", + "table": "storage_cleanup_jobs", + "def_hash": "285fe5104ce348207613e4bd62f0803d" + }, { "def": "CREATE INDEX storage_cleanup_jobs_document_idx ON public.storage_cleanup_jobs USING btree (document_id)", "name": "storage_cleanup_jobs_document_idx", @@ -18453,6 +18467,16 @@ "def": "CREATE TRIGGER work_course_bookings_updated_at BEFORE UPDATE ON public.work_course_bookings FOR EACH ROW EXECUTE FUNCTION public.set_updated_at()", "name": "work_course_bookings_updated_at", "table": "work_course_bookings" + }, + { + "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", + "table": "work_hospital_teams" + }, + { + "def": "CREATE TRIGGER work_whole_team_supervisor_scope BEFORE INSERT OR UPDATE ON public.work_role_grants FOR EACH ROW EXECUTE FUNCTION public.work_guard_whole_team_supervisor()", + "name": "work_whole_team_supervisor_scope", + "table": "work_role_grants" } ], "functions": [ @@ -18824,7 +18848,15 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "eefeb8717ae64afc070c1c312a5daf12", + "def_hash": "bc89ab638134abac5c12dd51fbce0284", + "signature": "public.cme_remove_evidence_v2(uuid,uuid,text)" + }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "d49e3fb5c0812ebc682ae49dce4ff426", "signature": "public.cme_remove_evidence(uuid,uuid,text)" }, { @@ -19687,6 +19719,14 @@ "def_hash": "4511c6bd98f3219a61f5ddd44f644e76", "signature": "public.purge_expired_rag_response_cache(integer)" }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "6495e84c303f7bfb857743ff77c613ee", + "signature": "public.rag_cache_scope_revision_v1(uuid,boolean,uuid[])" + }, { "acl": [ "postgres=X/postgres", @@ -19824,6 +19864,14 @@ "def_hash": "874163cf5aa0b818f3e88ca1ac6f90b2", "signature": "public.record_source_review(uuid,uuid,text,text,text[],date,uuid)" }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "6855830fb99d24b7e0d3d47f477ffbbb", + "signature": "public.recover_ingestion_job(uuid,text,jsonb)" + }, { "acl": [ "postgres=X/postgres", @@ -20656,7 +20704,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "a614e81532918d22c3a44d3d4008c54e", + "def_hash": "e0f61bafe44727727e8d3c8ff1c8164e", "signature": "public.teaching_depth_command(uuid,uuid,text,jsonb)" }, { @@ -21016,6 +21064,30 @@ "def_hash": "648b41850477258c0d015136b043bb98", "signature": "public.work_cancel_course(uuid,uuid)" }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "f7b8cb8cfd954642c48c0358ddd8f619", + "signature": "public.work_guard_whole_team_supervisor()" + }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "fdf89b88112ab0575fe3e595348dc200", + "signature": "public.work_move_team_v1(uuid,uuid,uuid,uuid)" + }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "53214a1cf5949106be9192b3460ef059", + "signature": "public.work_revoke_supervisors_on_team_move()" + }, { "acl": [ "postgres=X/postgres", @@ -24722,6 +24794,11 @@ "name": "source_review_events_reason_check", "table": "source_review_events" }, + { + "def": "FOREIGN KEY (cme_evidence_id) REFERENCES public.cme_evidence(id) ON DELETE SET NULL", + "name": "storage_cleanup_jobs_cme_evidence_id_fkey", + "table": "storage_cleanup_jobs" + }, { "def": "FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE SET NULL", "name": "storage_cleanup_jobs_document_id_fkey", diff --git a/supabase/migrations/20261018082910_audit_durable_recovery_pairing_and_cache_fences.sql b/supabase/migrations/20261018082910_audit_durable_recovery_pairing_and_cache_fences.sql new file mode 100644 index 0000000000..edbc209576 --- /dev/null +++ b/supabase/migrations/20261018082910_audit_durable_recovery_pairing_and_cache_fences.sql @@ -0,0 +1,569 @@ +-- DRAFT. Merge only inside the owner's approved database-change window. +set local lock_timeout = '5s'; +set local statement_timeout = '30s'; + +-- F02: the tombstone cannot commit without its exact, durable cleanup obligation. +alter table public.storage_cleanup_jobs add column cme_evidence_id uuid + references public.cme_evidence(id) on delete set null; +create unique index storage_cleanup_jobs_cme_evidence_key on public.storage_cleanup_jobs(cme_evidence_id) + where cme_evidence_id is not null; +create or replace function public.cme_remove_evidence(p_owner_id uuid, p_evidence_id uuid, p_reason text) +returns public.cme_evidence +language plpgsql security invoker set search_path = public, pg_temp as $$ +declare v_row public.cme_evidence%rowtype; +begin + if p_reason is null or length(btrim(p_reason)) not between 3 and 500 then + raise exception 'cme_evidence_removal_reason_invalid'; + end if; + perform pg_advisory_xact_lock(hashtextextended(p_owner_id::text, 23092026)); + select * into v_row from public.cme_evidence where id = p_evidence_id and owner_id = p_owner_id for update; + if not found then raise exception 'cme_evidence_not_found'; end if; + insert into public.storage_cleanup_jobs(owner_id, cme_evidence_id, document_bucket, document_paths, image_bucket, image_paths) + values (p_owner_id, v_row.id, 'cme-private-evidence', array[v_row.storage_path], 'cme-private-evidence', '{}') + on conflict (cme_evidence_id) where cme_evidence_id is not null do nothing; + if v_row.removed_at is null then + update public.cme_evidence set removed_at = now(), removal_reason = btrim(p_reason), file_name = 'Removed file' + where id = v_row.id and owner_id = p_owner_id returning * into v_row; + end if; + return v_row; +end $$; +alter function public.cme_remove_evidence(uuid,uuid,text) owner to postgres; +revoke all on function public.cme_remove_evidence(uuid,uuid,text) from public, anon, authenticated; +grant execute on function public.cme_remove_evidence(uuid,uuid,text) to service_role; + +-- The versioned entry point makes app-before-SQL fail before any tombstone write. +-- Retain the corrected original for old callers during a SQL-first rollout. +create function public.cme_remove_evidence_v2(p_owner_id uuid,p_evidence_id uuid,p_reason text) +returns public.cme_evidence language sql security invoker set search_path = '' as $$ + select public.cme_remove_evidence(p_owner_id,p_evidence_id,p_reason) +$$; +alter function public.cme_remove_evidence_v2(uuid,uuid,text) owner to postgres; +revoke all on function public.cme_remove_evidence_v2(uuid,uuid,text) from public, anon, authenticated; +grant execute on function public.cme_remove_evidence_v2(uuid,uuid,text) to service_role; + +-- Reconcile pre-existing tombstones too, including earlier lost-response removals. +-- Deleting an already-absent object is idempotent in the existing janitor. +insert into public.storage_cleanup_jobs(owner_id,cme_evidence_id,document_bucket,document_paths,image_bucket,image_paths) +select owner_id,id,'cme-private-evidence',array[storage_path],'cme-private-evidence','{}'::text[] +from public.cme_evidence where removed_at is not null +on conflict (cme_evidence_id) where cme_evidence_id is not null do nothing; + +-- F03: recovery owns the same authority and job locks as claims until its single +-- scheduling transaction commits. Preserve every artifact and generation. A next +-- worker claim mints a new write generation; no obsolete unfenced reset is called. +create function public.recover_ingestion_job(p_job_id uuid, p_action text, p_expected jsonb) +returns jsonb language plpgsql security definer set search_path = '' set lock_timeout = '5s' as $$ +declare v_document_id uuid; v_locked jsonb; v_job public.ingestion_jobs%rowtype; v_now timestamptz; v_active boolean; v_indexed boolean; +begin + if p_action is null or p_action not in ('retry','supersede') + or jsonb_typeof(p_expected) is distinct from 'object' + or not (p_expected ?& array['jobStatus','jobLockedAt','jobLeaseToken','jobLeaseExpiresAt','documentStatus','documentOwnerId']) then + raise exception 'invalid_ingestion_recovery_request'; + end if; + select document_id into v_document_id from public.ingestion_jobs where id = p_job_id; + if not found then return jsonb_build_object('ok',false,'reason','job_closed'); end if; + v_locked := public.lock_ingestion_document_authority(v_document_id, false); + if v_locked is null then return jsonb_build_object('ok',false,'reason','authority_unavailable'); end if; + perform 1 from public.ingestion_jobs where document_id = v_document_id order by id for update; + perform 1 from public.indexing_v3_agent_jobs where document_id = v_document_id order by id for update; + select * into v_job from public.ingestion_jobs where id = p_job_id and document_id = v_document_id; + if not found or v_job.status not in ('failed','processing') + or v_job.status is distinct from p_expected->>'jobStatus' + or v_job.locked_at is distinct from (p_expected->>'jobLockedAt')::timestamptz + or v_job.lease_token is distinct from (p_expected->>'jobLeaseToken')::uuid + or v_job.lease_expires_at is distinct from (p_expected->>'jobLeaseExpiresAt')::timestamptz + or v_locked->'document'->>'status' is distinct from p_expected->>'documentStatus' + or v_locked->'document'->>'owner_id' is distinct from p_expected->>'documentOwnerId' then + return jsonb_build_object('ok',false,'reason','state_changed'); + end if; + v_now := pg_catalog.clock_timestamp(); + if v_job.status = 'processing' and v_job.lease_expires_at > v_now then + return jsonb_build_object('ok',false,'reason','active_lease'); + end if; + v_active := exists(select 1 from public.ingestion_jobs where document_id = v_document_id and id <> p_job_id + and (status = 'pending' or status = 'processing' and lease_expires_at > v_now)) + or exists(select 1 from public.indexing_v3_agent_jobs where document_id = v_document_id + and status = 'processing' and lease_expires_at > v_now); + v_indexed := v_locked->'document'->>'status' = 'indexed' + and coalesce((v_locked->'document'->>'chunk_count')::integer,0) > 0; + if p_action = 'retry' then + if v_active or v_indexed or exists(select 1 from public.ingestion_jobs where document_id = v_document_id + and id <> p_job_id and status in ('pending','processing')) then + return jsonb_build_object('ok',false,'reason','state_changed'); + end if; + if exists(select 1 from ingestion_generation_evidence.candidates where document_id = v_document_id) then + return jsonb_build_object('ok',false,'reason','managed_recovery_required'); + end if; + update public.documents set status = 'queued', error_message = null where id = v_document_id; + elsif not (v_active or v_indexed) then + -- Stale redundant siblings may be closed before retry, in stable job id order. + if not exists(select 1 from public.ingestion_jobs where document_id = v_document_id and id <> p_job_id + and status in ('failed','processing')) then + return jsonb_build_object('ok',false,'reason','state_changed'); + end if; + end if; + update public.ingestion_jobs set status = case when p_action = 'retry' then 'pending' else 'completed' end, + stage = case when p_action = 'retry' then 'queued after recovery' else 'superseded during recovery' end, + progress = case when p_action = 'retry' then 0 else 100 end, + attempt_count = case when p_action = 'retry' then 0 else attempt_count end, + next_run_at = case when p_action = 'retry' then v_now else null end, + completed_at = case when p_action = 'retry' then null else v_now end, + error_message = null, locked_by = null, locked_at = null, + lease_token = null, lease_expected_generation_id = null, lease_write_generation_id = null, + lease_authority_digest = null, lease_expires_at = null, lease_duration_seconds = null + where id = p_job_id; + return jsonb_build_object('ok',true,'action',p_action,'artifacts_preserved',true); +end $$; +alter function public.recover_ingestion_job(uuid,text,jsonb) owner to postgres; +revoke all on function public.recover_ingestion_job(uuid,text,jsonb) from public, anon, authenticated; +grant execute on function public.recover_ingestion_job(uuid,text,jsonb) to service_role; + +-- F13: one statement snapshot of the complete effective owner/public/document scope. +-- Per-row fixed-size hashes bound aggregate memory without returning document metadata. +create function public.rag_cache_scope_revision_v1(p_owner_id uuid, p_include_public boolean, p_document_ids uuid[] default null) +returns text language sql stable security invoker set search_path = '' as $$ + select 'rag-cache-scope-v1:' || pg_catalog.md5(coalesce(pg_catalog.string_agg(row_hash, '' order by id),'')) + from ( + select d.id, pg_catalog.md5(pg_catalog.jsonb_build_array(d.id,d.owner_id,d.status,d.updated_at,d.metadata,d.index_generation_id)::text) as row_hash + from public.documents d + where (d.owner_id = p_owner_id or (coalesce(p_include_public,false) and d.owner_id is null)) + and (coalesce(cardinality(p_document_ids),0) = 0 or d.id = any(p_document_ids)) + ) revision +$$; +alter function public.rag_cache_scope_revision_v1(uuid,boolean,uuid[]) owner to postgres; +revoke all on function public.rag_cache_scope_revision_v1(uuid,boolean,uuid[]) from public, anon, authenticated; +grant execute on function public.rag_cache_scope_revision_v1(uuid,boolean,uuid[]) to service_role; + +-- HY05: team movement and removal of its whole-team authority commit together. +create function public.work_revoke_supervisors_on_team_move() returns trigger +language plpgsql security invoker set search_path = '' as $$ +begin + if new.hospital_id is distinct from old.hospital_id then + update public.work_role_grants set revoked_at = now(), revoked_by = new.linked_by + where role = 'supervisor' and service_id = new.service_id and subject_user_id is null and revoked_at is null; + end if; + return new; +end $$; +alter function public.work_revoke_supervisors_on_team_move() owner to postgres; +revoke all on function public.work_revoke_supervisors_on_team_move() from public, anon, authenticated; +grant execute on function public.work_revoke_supervisors_on_team_move() to service_role; +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(); + +-- A grant admitted against an old hospital must not race the move. Both paths +-- lock the team before writing authority. Revocation itself needs no team lookup. +create function public.work_guard_whole_team_supervisor() returns trigger +language plpgsql security invoker set search_path = '' as $$ +begin + if new.role = 'supervisor' and new.service_id is not null and new.revoked_at is null then + perform 1 from public.work_hospital_teams where service_id = new.service_id and hospital_id = new.hospital_id for share; + if not found then raise exception 'work_team_changed'; end if; + end if; + return new; +end $$; +alter function public.work_guard_whole_team_supervisor() owner to postgres; +revoke all on function public.work_guard_whole_team_supervisor() from public, anon, authenticated; +grant execute on function public.work_guard_whole_team_supervisor() to service_role; +create trigger work_whole_team_supervisor_scope before insert or update on public.work_role_grants +for each row execute function public.work_guard_whole_team_supervisor(); + +-- Versioned application entry point: refuse app-before-SQL instead of moving +-- without the transactional revocation trigger. Old callers remain trigger-protected. +create function public.work_move_team_v1(p_actor_id uuid,p_service_id uuid,p_expected_hospital_id uuid,p_to_hospital_id uuid) +returns uuid language plpgsql security definer set search_path = '' as $$ +begin + if not exists(select 1 from auth.users where id = p_actor_id + and raw_app_meta_data->>'site_role' = 'administrator') then + raise exception 'work_role_denied'; + end if; + perform 1 from public.work_hospitals where id = p_to_hospital_id and archived_at is null for share; + if not found then raise exception 'work_hospital_unavailable'; end if; + if p_expected_hospital_id is not distinct from p_to_hospital_id then raise exception 'work_team_changed'; end if; + update public.work_hospital_teams set hospital_id = p_to_hospital_id, linked_by = p_actor_id, linked_at = now() + where service_id = p_service_id and hospital_id = p_expected_hospital_id; + if not found then raise exception 'work_team_changed'; end if; + return p_expected_hospital_id; +end $$; +alter function public.work_move_team_v1(uuid,uuid,uuid,uuid) owner to postgres; +revoke all on function public.work_move_team_v1(uuid,uuid,uuid,uuid) from public, anon, authenticated; +grant execute on function public.work_move_team_v1(uuid,uuid,uuid,uuid) to service_role; + +-- F14 / HY06: pairing authority is locked before authorization and child writes. +create or replace function public.teaching_depth_command(p_actor_id uuid, p_service_id uuid, p_action text, p_payload jsonb default '{}') returns jsonb +language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$ +declare + v_service public.on_call_services; + v_role text; + v_occ public.teaching_occurrences; + v_pairing public.teaching_supervision_pairings; + v_entry public.teaching_supervision_entries; + v_note public.teaching_supervision_notes; + v_readiness public.teaching_readiness; + v_id uuid; + v_registrar uuid; + v_supervisor uuid; + v_ids uuid[]; + v_topics text[]; + v_starts date; + v_ends date; + v_date date; + v_target numeric; + v_text text; + v_minutes integer; + v_useful integer; + v_count integer; + v_result jsonb; + v_row jsonb; + v_leaver boolean := false; +begin + if p_actor_id is null then raise exception 'teaching_auth_required'; end if; + if p_action is null or p_payload is null or jsonb_typeof(p_payload) <> 'object' or p_service_id is null + or p_action not in ('pairing.save','pairing.reassign','supervision.log','supervision.confirm','supervision.note', + 'supervision.note.confirm','supervision.read','supervision.target.set','readiness.set','readiness.deid.confirm', + 'feedback.submit','feedback.totals','import.commit') then + raise exception 'teaching_invalid_request'; + end if; + select * into v_service from public.on_call_services where id = p_service_id for share; + if not found then raise exception 'teaching_access_denied'; end if; + v_role := public.teaching_member_role(p_service_id, p_actor_id); + if v_role is null then + -- A leaver keeps read-only access to their own supervision entries (as registrar) for 90 days + -- from the shared revoke, as logbook.read keeps their attendance (spec §9). Nothing else. + v_leaver := p_action = 'supervision.read' and exists (select 1 from public.on_call_service_members m + where m.service_id = p_service_id and m.user_id = p_actor_id + and m.revoked_at is not null and m.revoked_at > now() - interval '90 days'); + if not v_leaver then raise exception 'teaching_access_denied'; end if; + end if; + if p_action not in ('supervision.read','feedback.totals') and not (v_service.verified_at is not null or v_service.is_demo) then + raise exception 'teaching_team_unverified'; + end if; + if p_action in ('pairing.save','pairing.reassign','import.commit') then + perform pg_advisory_xact_lock(hashtextextended(p_service_id::text, 74818)); + end if; + + if p_action = 'pairing.save' then + v_id := public.teaching_uuid_arg(p_payload, 'pairingId'); + v_registrar := public.teaching_uuid_arg(p_payload, 'registrarId'); + v_supervisor := public.teaching_uuid_arg(p_payload, 'supervisorId'); + v_starts := public.teaching_date_arg(p_payload, 'startsOn'); + v_ends := public.teaching_date_arg(p_payload, 'endsOn'); + -- The hours target is the registrar's own (supervision.target.set); an organiser never sets it. + if p_payload ? 'targetHours' then raise exception 'teaching_invalid_request'; end if; + -- Organisers pair other people. A pairing that includes the actor needs a Teaching admin. + if not (v_role = 'admin' or (v_role = 'organiser' and p_actor_id <> v_registrar and p_actor_id <> v_supervisor)) then + raise exception 'teaching_role_denied'; + end if; + if v_registrar is null or v_supervisor is null or v_registrar = v_supervisor + or v_starts is null or v_ends is null or v_ends < v_starts or v_ends > v_starts + 731 + or not public.service_member_active(p_service_id, v_registrar) + or not public.service_member_active(p_service_id, v_supervisor) then + raise exception 'teaching_invalid_request'; + end if; + if v_id is null then + if (select count(*) from public.teaching_supervision_pairings where service_id = p_service_id) >= 5000 then + raise exception 'teaching_limit'; + end if; + insert into public.teaching_supervision_pairings(service_id, registrar_id, supervisor_id, starts_on, ends_on, created_by) + values (p_service_id, v_registrar, v_supervisor, v_starts, v_ends, p_actor_id) + returning * into v_pairing; + else + select * into v_pairing from public.teaching_supervision_pairings where id = v_id and service_id = p_service_id for update; + if not found then raise exception 'teaching_not_found'; end if; + -- Once a pairing has entries, only its end date and target can change; the supervisor + -- changes through pairing.reassign. + if exists (select 1 from public.teaching_supervision_entries e where e.pairing_id = v_pairing.id) + and (v_pairing.registrar_id is distinct from v_registrar or v_pairing.supervisor_id is distinct from v_supervisor + or v_pairing.starts_on <> v_starts) then + raise exception 'teaching_invalid_request'; + end if; + update public.teaching_supervision_pairings set registrar_id = v_registrar, supervisor_id = v_supervisor, + starts_on = v_starts, ends_on = v_ends, updated_at = now() + where id = v_pairing.id returning * into v_pairing; + end if; + perform public.teaching_audit(p_service_id, p_actor_id, 'pairing.save', v_pairing.id); + return jsonb_build_object('pairingId', v_pairing.id); + + elsif p_action = 'pairing.reassign' then + v_id := public.teaching_uuid_arg(p_payload, 'pairingId'); + v_supervisor := public.teaching_uuid_arg(p_payload, 'supervisorId'); + select * into v_pairing from public.teaching_supervision_pairings where id = v_id and service_id = p_service_id for update; + if not found then raise exception 'teaching_not_found'; end if; + if not (v_role = 'admin' or (v_role = 'organiser' and p_actor_id is distinct from v_pairing.registrar_id and p_actor_id <> v_supervisor)) then + raise exception 'teaching_role_denied'; + end if; + if v_supervisor is null or v_supervisor is not distinct from v_pairing.registrar_id + or not public.service_member_active(p_service_id, v_supervisor) then + raise exception 'teaching_invalid_request'; + end if; + -- Confirmed entries keep who confirmed them; pending entries and notes now wait for the new + -- supervisor, because confirmation always checks the pairing's current supervisor. + update public.teaching_supervision_pairings set supervisor_id = v_supervisor, updated_at = now() where id = v_pairing.id; + select count(*) into v_count from public.teaching_supervision_entries where pairing_id = v_pairing.id and status = 'pending'; + perform public.teaching_audit(p_service_id, p_actor_id, 'pairing.reassign', v_pairing.id); + return jsonb_build_object('pairingId', v_pairing.id, 'pendingMoved', v_count); + + elsif p_action = 'supervision.target.set' then + -- The registrar's own hours target for this pairing: nullable, no default, and progress is shown + -- only once it is set. Only the pairing's registrar sets or clears it (targetHours null). + v_id := public.teaching_uuid_arg(p_payload, 'pairingId'); + select * into v_pairing from public.teaching_supervision_pairings where id = v_id and service_id = p_service_id for update; + if not found then raise exception 'teaching_not_found'; end if; + if v_pairing.registrar_id is distinct from p_actor_id then raise exception 'teaching_role_denied'; end if; + if not (p_payload ? 'targetHours') then raise exception 'teaching_invalid_request'; end if; + if p_payload->'targetHours' <> 'null'::jsonb then + if jsonb_typeof(p_payload->'targetHours') <> 'number' or (p_payload->>'targetHours') !~ '^[0-9]{1,3}(\.[0-9])?$' then + raise exception 'teaching_invalid_request'; + end if; + v_target := (p_payload->>'targetHours')::numeric; + if v_target not between 1 and 500 then raise exception 'teaching_invalid_request'; end if; + end if; + update public.teaching_supervision_pairings set target_hours = v_target, updated_at = now() where id = v_pairing.id; + perform public.teaching_audit(p_service_id, p_actor_id, 'supervision.target.set', v_pairing.id); + return jsonb_build_object('pairingId', v_pairing.id, 'targetHours', v_target); + + elsif p_action = 'supervision.log' then + v_id := public.teaching_uuid_arg(p_payload, 'pairingId'); + select * into v_pairing from public.teaching_supervision_pairings where id = v_id and service_id = p_service_id for update; + if not found then raise exception 'teaching_not_found'; end if; + if v_pairing.registrar_id is distinct from p_actor_id then raise exception 'teaching_role_denied'; end if; + v_date := public.teaching_date_arg(p_payload, 'date'); + v_minutes := public.teaching_int_arg(p_payload, 'minutes'); + v_text := public.teaching_text_arg(p_payload, 'type'); + v_topics := public.teaching_text_array_arg(p_payload, 'topics', 5); + if v_date is null or v_date < v_pairing.starts_on or v_date > v_pairing.ends_on + or v_date > (now() at time zone 'Australia/Perth')::date + or v_minutes is null or v_minutes not between 15 and 240 or v_minutes % 15 <> 0 + or v_text is null or v_text not in ('individual','group') + -- Topics are optional: none to five. + or cardinality(v_topics) not between 0 and 5 + or not (v_topics <@ array['case_review','risk','psychotherapy','formulation','medication','mha_legal','teaching_skills','career','exam_prep','wellbeing','other']::text[]) then + raise exception 'teaching_invalid_request'; + end if; + if (select count(*) from public.teaching_supervision_entries where pairing_id = v_pairing.id) >= 1000 then + raise exception 'teaching_limit'; + end if; + insert into public.teaching_supervision_entries(pairing_id, service_id, session_date, minutes, type, topics, logged_by) + values (v_pairing.id, p_service_id, v_date, v_minutes, v_text, v_topics, p_actor_id) + returning * into v_entry; + perform public.teaching_audit(p_service_id, p_actor_id, 'supervision.log', v_entry.id); + return jsonb_build_object('entryId', v_entry.id); + + elsif p_action = 'supervision.confirm' then + v_ids := public.teaching_uuid_array_arg(p_payload, 'entryIds', 20); + if cardinality(v_ids) = 0 then raise exception 'teaching_invalid_request'; end if; + -- Parent before child, sorted for multi-pairing batches. Authorization is checked + -- after any wait, so confirmation linearizes with reassignment; a confirmation + -- committed first remains historical evidence under its original supervisor. + perform p.id from public.teaching_supervision_pairings p + where p.service_id = p_service_id and p.id in ( + select e.pairing_id from public.teaching_supervision_entries e + where e.id = any(v_ids) and e.service_id = p_service_id) + order by p.id for update; + perform e.id from public.teaching_supervision_entries e + where e.id = any(v_ids) and e.service_id = p_service_id order by e.id for update; + -- All or nothing: every entry must be pending and belong to a pairing the actor now supervises. + if (select count(*) from public.teaching_supervision_entries e + join public.teaching_supervision_pairings p on p.id = e.pairing_id + where e.id = any(v_ids) and e.service_id = p_service_id and e.status = 'pending' and p.supervisor_id = p_actor_id) + <> cardinality(v_ids) then + raise exception 'teaching_not_found'; + end if; + update public.teaching_supervision_entries set status = 'confirmed', confirmed_by = p_actor_id, confirmed_at = now(), locked = true + where id = any(v_ids); + insert into public.teaching_audit_events(service_id, actor_id, action, subject_id) + select p_service_id, p_actor_id, 'supervision.confirm', u from unnest(v_ids) as u; + return jsonb_build_object('confirmed', cardinality(v_ids)); + + elsif p_action = 'supervision.note' then + v_id := public.teaching_uuid_arg(p_payload, 'entryId'); + perform p.id from public.teaching_supervision_pairings p + join public.teaching_supervision_entries e on e.pairing_id = p.id + where e.id = v_id and e.service_id = p_service_id for update of p; + select e.* into v_entry from public.teaching_supervision_entries e + join public.teaching_supervision_pairings p on p.id = e.pairing_id + where e.id = v_id and e.service_id = p_service_id and p.registrar_id = p_actor_id; + if not found then raise exception 'teaching_not_found'; end if; + v_text := public.teaching_text_arg(p_payload, 'reason'); + if v_text is null or not public.teaching_valid_correction(v_text, p_payload->'correctedValue') then + raise exception 'teaching_invalid_request'; + end if; + if v_text = 'wrong_date' then + perform public.teaching_date_arg(p_payload->'correctedValue', 'date'); + end if; + if (select count(*) from public.teaching_supervision_notes where entry_id = v_entry.id and confirmed_at is null) >= 5 then + raise exception 'teaching_limit'; + end if; + insert into public.teaching_supervision_notes(entry_id, service_id, reason, corrected_value, created_by) + values (v_entry.id, p_service_id, v_text, p_payload->'correctedValue', p_actor_id) + returning * into v_note; + perform public.teaching_audit(p_service_id, p_actor_id, 'supervision.note', v_note.id); + return jsonb_build_object('noteId', v_note.id); + + elsif p_action = 'supervision.note.confirm' then + v_id := public.teaching_uuid_arg(p_payload, 'noteId'); + perform p.id from public.teaching_supervision_pairings p + join public.teaching_supervision_entries e on e.pairing_id = p.id + join public.teaching_supervision_notes n on n.entry_id = e.id + where n.id = v_id and n.service_id = p_service_id for update of p; + select n.* into v_note from public.teaching_supervision_notes n + join public.teaching_supervision_entries e on e.id = n.entry_id + join public.teaching_supervision_pairings p on p.id = e.pairing_id + where n.id = v_id and n.service_id = p_service_id and p.supervisor_id = p_actor_id and n.confirmed_at is null + for update of n; + if not found then raise exception 'teaching_not_found'; end if; + update public.teaching_supervision_notes set confirmed_by = p_actor_id, confirmed_at = now() where id = v_note.id; + perform public.teaching_audit(p_service_id, p_actor_id, 'supervision.note.confirm', v_note.id); + return jsonb_build_object('noteId', v_note.id); + + elsif p_action = 'supervision.read' then + -- The registrar and supervisor of a pairing see its entries; an organiser sees totals and + -- status only (never topics). Reading anyone else's pairing is audited. A leaver (v_leaver) + -- sees only the pairings where they are the registrar, and reads their own record unaudited. + v_id := public.teaching_uuid_arg(p_payload, 'pairingId'); + if v_id is not null and not exists ( + select 1 from public.teaching_supervision_pairings p where p.id = v_id and p.service_id = p_service_id + and (p.registrar_id = p_actor_id or (not v_leaver and (p.supervisor_id = p_actor_id or v_role = 'organiser')))) then + raise exception 'teaching_not_found'; + end if; + if v_id is null and v_role = 'organiser' then + -- An organiser's whole-team read (Logbook, Today): one service-level row per call. + perform public.teaching_audit(p_service_id, p_actor_id, 'supervision.read', null); + elsif not v_leaver then + insert into public.teaching_audit_events(service_id, actor_id, action, subject_id) + select p_service_id, p_actor_id, 'supervision.read', p.id + from public.teaching_supervision_pairings p + where p.service_id = p_service_id and (v_id is null or p.id = v_id) + and p.registrar_id is distinct from p_actor_id + and (p.supervisor_id = p_actor_id or v_role = 'organiser'); + end if; + select jsonb_build_object('pairings', coalesce(jsonb_agg(jsonb_build_object( + 'pairingId', p.id, + 'access', case when p.registrar_id = p_actor_id then 'registrar' when p.supervisor_id = p_actor_id then 'supervisor' else 'organiser' end, + 'registrarName', case when p.registrar_id is null then 'Former member' else coalesce((select coalesce(m.display_name, 'Member') + from public.on_call_service_members m where m.service_id = p.service_id and m.user_id = p.registrar_id), 'Former member') end, + 'supervisorName', case when p.supervisor_id is null then 'Former member' else coalesce((select coalesce(m.display_name, 'Member') + from public.on_call_service_members m where m.service_id = p.service_id and m.user_id = p.supervisor_id), 'Former member') end, + 'startsOn', p.starts_on, 'endsOn', p.ends_on, + 'targetHours', case when p.registrar_id = p_actor_id or p.supervisor_id = p_actor_id then p.target_hours else null end, + 'confirmedMinutes', (select coalesce(sum(e.minutes), 0) from public.teaching_supervision_entries e where e.pairing_id = p.id and e.status = 'confirmed'), + 'pendingMinutes', (select coalesce(sum(e.minutes), 0) from public.teaching_supervision_entries e where e.pairing_id = p.id and e.status = 'pending'), + 'pendingCount', (select count(*) from public.teaching_supervision_entries e where e.pairing_id = p.id and e.status = 'pending'), + 'oldestPendingAt', (select min(e.created_at) from public.teaching_supervision_entries e where e.pairing_id = p.id and e.status = 'pending'), + 'entries', case when p.registrar_id = p_actor_id or p.supervisor_id = p_actor_id then coalesce((select jsonb_agg(jsonb_build_object( + 'entryId', e.id, 'date', e.session_date, 'minutes', e.minutes, 'type', e.type, 'topics', to_jsonb(e.topics), + 'status', e.status, 'confirmedAt', e.confirmed_at, + 'confirmedByName', case when e.confirmed_at is null then null when e.confirmed_by is null then 'Former member' else coalesce( + (select coalesce(m.display_name, 'Member') from public.on_call_service_members m where m.service_id = e.service_id and m.user_id = e.confirmed_by), + 'Former member') end, + 'notes', coalesce((select jsonb_agg(jsonb_build_object('noteId', n.id, 'reason', n.reason, 'correctedValue', n.corrected_value, + 'createdAt', n.created_at, 'confirmedAt', n.confirmed_at) order by n.created_at) + from public.teaching_supervision_notes n where n.entry_id = e.id), '[]'::jsonb) + ) order by e.session_date desc, e.created_at desc) from public.teaching_supervision_entries e where e.pairing_id = p.id), '[]'::jsonb) + else null end + ) order by p.starts_on desc, p.id), '[]'::jsonb)) + into v_result + from public.teaching_supervision_pairings p + where p.service_id = p_service_id and (v_id is null or p.id = v_id) + and (p.registrar_id = p_actor_id or (not v_leaver and (p.supervisor_id = p_actor_id or v_role = 'organiser'))); + return v_result; + + elsif p_action in ('readiness.set','readiness.deid.confirm') then + v_id := public.teaching_uuid_arg(p_payload, 'occurrenceId'); + select * into v_occ from public.teaching_occurrences where id = v_id and service_id = p_service_id; + if not found then raise exception 'teaching_not_found'; end if; + if v_occ.presenter_id is distinct from p_actor_id then raise exception 'teaching_role_denied'; end if; + insert into public.teaching_readiness(occurrence_id, service_id) values (v_occ.id, p_service_id) + on conflict (occurrence_id) do nothing; + if p_action = 'readiness.set' then + v_text := public.teaching_text_arg(p_payload, 'item'); + if v_text is null or v_text not in ('reading_list','aims','slides_link','room') + or jsonb_typeof(p_payload->'done') is distinct from 'boolean' then + raise exception 'teaching_invalid_request'; + end if; + update public.teaching_readiness set + items = case when (p_payload->>'done')::boolean + then array(select distinct i from unnest(array_append(items, v_text)) as i order by i) + else array_remove(items, v_text) end, + updated_at = now() + where occurrence_id = v_occ.id returning * into v_readiness; + else + update public.teaching_readiness set deid_confirmed_at = now(), deid_confirmed_by = p_actor_id, updated_at = now() + where occurrence_id = v_occ.id returning * into v_readiness; + end if; + perform public.teaching_audit(p_service_id, p_actor_id, p_action, v_occ.id); + return jsonb_build_object('items', to_jsonb(v_readiness.items), 'deidConfirmedAt', v_readiness.deid_confirmed_at); + + elsif p_action = 'feedback.submit' then + v_id := public.teaching_uuid_arg(p_payload, 'occurrenceId'); + select * into v_occ from public.teaching_occurrences where id = v_id and service_id = p_service_id; + if not found then raise exception 'teaching_not_found'; end if; + if not exists (select 1 from public.teaching_attendance a where a.occurrence_id = v_occ.id and a.user_id = p_actor_id) then + raise exception 'teaching_not_attended'; + end if; + if v_occ.status = 'cancelled' or now() < v_occ.ends_at or now() > v_occ.ends_at + interval '7 days' then + raise exception 'teaching_window_closed'; + end if; + v_useful := public.teaching_int_arg(p_payload, 'useful'); + v_text := public.teaching_text_arg(p_payload, 'pace'); + if v_useful is null or v_useful not between 1 and 5 or v_text is null or v_text not in ('slow','right','fast') then + raise exception 'teaching_invalid_request'; + end if; + -- One-way "already answered" marker. No responder id is stored with feedback anywhere. + insert into public.teaching_feedback_replied(occurrence_id, reply_marker) + values (v_occ.id, encode(extensions.digest(convert_to('teaching-feedback:' || v_occ.id::text || ':' || p_actor_id::text, 'UTF8'), 'sha256'), 'hex')) + on conflict (occurrence_id, reply_marker) do nothing; + if not found then raise exception 'teaching_limit'; end if; + insert into public.teaching_feedback_answers(occurrence_id, service_id, useful, pace) + values (v_occ.id, p_service_id, v_useful, v_text); + -- No actor on this audit row: nothing may tie a person to the moment an answer was written. + perform public.teaching_audit(p_service_id, null, 'feedback.submit', v_occ.id); + return '{}'::jsonb; + + elsif p_action = 'import.commit' then + -- A term from a spreadsheet, all or nothing: every row is a new series through the same save as + -- series.save, inside this one transaction, so one refused row rolls back every row before it. + -- Spec §4 gives import to organisers and admins. + if v_role not in ('organiser','admin') then raise exception 'teaching_role_denied'; end if; + if jsonb_typeof(p_payload->'rows') is distinct from 'array' + or jsonb_array_length(p_payload->'rows') not between 1 and 200 then + raise exception 'teaching_invalid_request'; + end if; + v_count := 0; + for v_row in select r.value from jsonb_array_elements(p_payload->'rows') as r(value) loop + if jsonb_typeof(v_row) is distinct from 'object' or v_row ? 'seriesId' then + raise exception 'teaching_invalid_request'; + end if; + v_result := public.teaching_series_save(p_actor_id, p_service_id, v_row); + perform public.teaching_audit(p_service_id, p_actor_id, 'series.save', public.teaching_uuid_arg(v_result, 'seriesId')); + v_count := v_count + public.teaching_int_arg(v_result, 'occurrences'); + end loop; + perform public.teaching_audit(p_service_id, p_actor_id, 'import.commit', null); + return jsonb_build_object('series', jsonb_array_length(p_payload->'rows'), 'occurrences', v_count); + + elsif p_action = 'feedback.totals' then + v_id := public.teaching_uuid_arg(p_payload, 'occurrenceId'); + select * into v_occ from public.teaching_occurrences where id = v_id and service_id = p_service_id; + if not found then raise exception 'teaching_not_found'; end if; + if (v_occ.presenter_id = p_actor_id or v_role = 'organiser') is not true then raise exception 'teaching_role_denied'; end if; + select count(*) into v_count from public.teaching_feedback_answers where occurrence_id = v_occ.id; + if now() < v_occ.ends_at + interval '7 days' or v_count < 5 then + return jsonb_build_object('released', false); + end if; + perform public.teaching_audit(p_service_id, p_actor_id, 'feedback.totals', v_occ.id); + return jsonb_build_object( + 'released', true, + 'replies', v_count, + 'useful', (select jsonb_build_object('1', count(*) filter (where useful = 1), '2', count(*) filter (where useful = 2), + '3', count(*) filter (where useful = 3), '4', count(*) filter (where useful = 4), '5', count(*) filter (where useful = 5)) + from public.teaching_feedback_answers where occurrence_id = v_occ.id), + 'pace', (select jsonb_build_object('slow', count(*) filter (where pace = 'slow'), 'right', count(*) filter (where pace = 'right'), + 'fast', count(*) filter (where pace = 'fast')) + from public.teaching_feedback_answers where occurrence_id = v_occ.id) + ); + end if; + + raise exception 'teaching_invalid_request'; +end $$; +alter function public.teaching_depth_command(uuid,uuid,text,jsonb) owner to postgres; +revoke all on function public.teaching_depth_command(uuid,uuid,text,jsonb) from public, anon, authenticated; +grant execute on function public.teaching_depth_command(uuid,uuid,text,jsonb) to service_role; diff --git a/supabase/schema.sql b/supabase/schema.sql index 19da197013..8e6db93a47 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -34921,3 +34921,574 @@ end $$; alter function public.claim_ingestion_jobs(text, integer, integer) owner to postgres; revoke all on function public.claim_ingestion_jobs(text, integer, integer) from public, anon, authenticated; grant execute on function public.claim_ingestion_jobs(text, integer, integer) to service_role; + +-- Projection: 20261018082910_audit_durable_recovery_pairing_and_cache_fences.sql +-- DRAFT. Merge only inside the owner's approved database-change window. +set local lock_timeout = '5s'; +set local statement_timeout = '30s'; + +-- F02: the tombstone cannot commit without its exact, durable cleanup obligation. +alter table public.storage_cleanup_jobs add column cme_evidence_id uuid + references public.cme_evidence(id) on delete set null; +create unique index storage_cleanup_jobs_cme_evidence_key on public.storage_cleanup_jobs(cme_evidence_id) + where cme_evidence_id is not null; +create or replace function public.cme_remove_evidence(p_owner_id uuid, p_evidence_id uuid, p_reason text) +returns public.cme_evidence +language plpgsql security invoker set search_path = public, pg_temp as $$ +declare v_row public.cme_evidence%rowtype; +begin + if p_reason is null or length(btrim(p_reason)) not between 3 and 500 then + raise exception 'cme_evidence_removal_reason_invalid'; + end if; + perform pg_advisory_xact_lock(hashtextextended(p_owner_id::text, 23092026)); + select * into v_row from public.cme_evidence where id = p_evidence_id and owner_id = p_owner_id for update; + if not found then raise exception 'cme_evidence_not_found'; end if; + insert into public.storage_cleanup_jobs(owner_id, cme_evidence_id, document_bucket, document_paths, image_bucket, image_paths) + values (p_owner_id, v_row.id, 'cme-private-evidence', array[v_row.storage_path], 'cme-private-evidence', '{}') + on conflict (cme_evidence_id) where cme_evidence_id is not null do nothing; + if v_row.removed_at is null then + update public.cme_evidence set removed_at = now(), removal_reason = btrim(p_reason), file_name = 'Removed file' + where id = v_row.id and owner_id = p_owner_id returning * into v_row; + end if; + return v_row; +end $$; +alter function public.cme_remove_evidence(uuid,uuid,text) owner to postgres; +revoke all on function public.cme_remove_evidence(uuid,uuid,text) from public, anon, authenticated; +grant execute on function public.cme_remove_evidence(uuid,uuid,text) to service_role; + +-- The versioned entry point makes app-before-SQL fail before any tombstone write. +-- Retain the corrected original for old callers during a SQL-first rollout. +create function public.cme_remove_evidence_v2(p_owner_id uuid,p_evidence_id uuid,p_reason text) +returns public.cme_evidence language sql security invoker set search_path = '' as $$ + select public.cme_remove_evidence(p_owner_id,p_evidence_id,p_reason) +$$; +alter function public.cme_remove_evidence_v2(uuid,uuid,text) owner to postgres; +revoke all on function public.cme_remove_evidence_v2(uuid,uuid,text) from public, anon, authenticated; +grant execute on function public.cme_remove_evidence_v2(uuid,uuid,text) to service_role; + +-- Reconcile pre-existing tombstones too, including earlier lost-response removals. +-- Deleting an already-absent object is idempotent in the existing janitor. +insert into public.storage_cleanup_jobs(owner_id,cme_evidence_id,document_bucket,document_paths,image_bucket,image_paths) +select owner_id,id,'cme-private-evidence',array[storage_path],'cme-private-evidence','{}'::text[] +from public.cme_evidence where removed_at is not null +on conflict (cme_evidence_id) where cme_evidence_id is not null do nothing; + +-- F03: recovery owns the same authority and job locks as claims until its single +-- scheduling transaction commits. Preserve every artifact and generation. A next +-- worker claim mints a new write generation; no obsolete unfenced reset is called. +create function public.recover_ingestion_job(p_job_id uuid, p_action text, p_expected jsonb) +returns jsonb language plpgsql security definer set search_path = '' set lock_timeout = '5s' as $$ +declare v_document_id uuid; v_locked jsonb; v_job public.ingestion_jobs%rowtype; v_now timestamptz; v_active boolean; v_indexed boolean; +begin + if p_action is null or p_action not in ('retry','supersede') + or jsonb_typeof(p_expected) is distinct from 'object' + or not (p_expected ?& array['jobStatus','jobLockedAt','jobLeaseToken','jobLeaseExpiresAt','documentStatus','documentOwnerId']) then + raise exception 'invalid_ingestion_recovery_request'; + end if; + select document_id into v_document_id from public.ingestion_jobs where id = p_job_id; + if not found then return jsonb_build_object('ok',false,'reason','job_closed'); end if; + v_locked := public.lock_ingestion_document_authority(v_document_id, false); + if v_locked is null then return jsonb_build_object('ok',false,'reason','authority_unavailable'); end if; + perform 1 from public.ingestion_jobs where document_id = v_document_id order by id for update; + perform 1 from public.indexing_v3_agent_jobs where document_id = v_document_id order by id for update; + select * into v_job from public.ingestion_jobs where id = p_job_id and document_id = v_document_id; + if not found or v_job.status not in ('failed','processing') + or v_job.status is distinct from p_expected->>'jobStatus' + or v_job.locked_at is distinct from (p_expected->>'jobLockedAt')::timestamptz + or v_job.lease_token is distinct from (p_expected->>'jobLeaseToken')::uuid + or v_job.lease_expires_at is distinct from (p_expected->>'jobLeaseExpiresAt')::timestamptz + or v_locked->'document'->>'status' is distinct from p_expected->>'documentStatus' + or v_locked->'document'->>'owner_id' is distinct from p_expected->>'documentOwnerId' then + return jsonb_build_object('ok',false,'reason','state_changed'); + end if; + v_now := pg_catalog.clock_timestamp(); + if v_job.status = 'processing' and v_job.lease_expires_at > v_now then + return jsonb_build_object('ok',false,'reason','active_lease'); + end if; + v_active := exists(select 1 from public.ingestion_jobs where document_id = v_document_id and id <> p_job_id + and (status = 'pending' or status = 'processing' and lease_expires_at > v_now)) + or exists(select 1 from public.indexing_v3_agent_jobs where document_id = v_document_id + and status = 'processing' and lease_expires_at > v_now); + v_indexed := v_locked->'document'->>'status' = 'indexed' + and coalesce((v_locked->'document'->>'chunk_count')::integer,0) > 0; + if p_action = 'retry' then + if v_active or v_indexed or exists(select 1 from public.ingestion_jobs where document_id = v_document_id + and id <> p_job_id and status in ('pending','processing')) then + return jsonb_build_object('ok',false,'reason','state_changed'); + end if; + if exists(select 1 from ingestion_generation_evidence.candidates where document_id = v_document_id) then + return jsonb_build_object('ok',false,'reason','managed_recovery_required'); + end if; + update public.documents set status = 'queued', error_message = null where id = v_document_id; + elsif not (v_active or v_indexed) then + -- Stale redundant siblings may be closed before retry, in stable job id order. + if not exists(select 1 from public.ingestion_jobs where document_id = v_document_id and id <> p_job_id + and status in ('failed','processing')) then + return jsonb_build_object('ok',false,'reason','state_changed'); + end if; + end if; + update public.ingestion_jobs set status = case when p_action = 'retry' then 'pending' else 'completed' end, + stage = case when p_action = 'retry' then 'queued after recovery' else 'superseded during recovery' end, + progress = case when p_action = 'retry' then 0 else 100 end, + attempt_count = case when p_action = 'retry' then 0 else attempt_count end, + next_run_at = case when p_action = 'retry' then v_now else null end, + completed_at = case when p_action = 'retry' then null else v_now end, + error_message = null, locked_by = null, locked_at = null, + lease_token = null, lease_expected_generation_id = null, lease_write_generation_id = null, + lease_authority_digest = null, lease_expires_at = null, lease_duration_seconds = null + where id = p_job_id; + return jsonb_build_object('ok',true,'action',p_action,'artifacts_preserved',true); +end $$; +alter function public.recover_ingestion_job(uuid,text,jsonb) owner to postgres; +revoke all on function public.recover_ingestion_job(uuid,text,jsonb) from public, anon, authenticated; +grant execute on function public.recover_ingestion_job(uuid,text,jsonb) to service_role; + +-- F13: one statement snapshot of the complete effective owner/public/document scope. +-- Per-row fixed-size hashes bound aggregate memory without returning document metadata. +create function public.rag_cache_scope_revision_v1(p_owner_id uuid, p_include_public boolean, p_document_ids uuid[] default null) +returns text language sql stable security invoker set search_path = '' as $$ + select 'rag-cache-scope-v1:' || pg_catalog.md5(coalesce(pg_catalog.string_agg(row_hash, '' order by id),'')) + from ( + select d.id, pg_catalog.md5(pg_catalog.jsonb_build_array(d.id,d.owner_id,d.status,d.updated_at,d.metadata,d.index_generation_id)::text) as row_hash + from public.documents d + where (d.owner_id = p_owner_id or (coalesce(p_include_public,false) and d.owner_id is null)) + and (coalesce(cardinality(p_document_ids),0) = 0 or d.id = any(p_document_ids)) + ) revision +$$; +alter function public.rag_cache_scope_revision_v1(uuid,boolean,uuid[]) owner to postgres; +revoke all on function public.rag_cache_scope_revision_v1(uuid,boolean,uuid[]) from public, anon, authenticated; +grant execute on function public.rag_cache_scope_revision_v1(uuid,boolean,uuid[]) to service_role; + +-- HY05: team movement and removal of its whole-team authority commit together. +create function public.work_revoke_supervisors_on_team_move() returns trigger +language plpgsql security invoker set search_path = '' as $$ +begin + if new.hospital_id is distinct from old.hospital_id then + update public.work_role_grants set revoked_at = now(), revoked_by = new.linked_by + where role = 'supervisor' and service_id = new.service_id and subject_user_id is null and revoked_at is null; + end if; + return new; +end $$; +alter function public.work_revoke_supervisors_on_team_move() owner to postgres; +revoke all on function public.work_revoke_supervisors_on_team_move() from public, anon, authenticated; +grant execute on function public.work_revoke_supervisors_on_team_move() to service_role; +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(); + +-- A grant admitted against an old hospital must not race the move. Both paths +-- lock the team before writing authority. Revocation itself needs no team lookup. +create function public.work_guard_whole_team_supervisor() returns trigger +language plpgsql security invoker set search_path = '' as $$ +begin + if new.role = 'supervisor' and new.service_id is not null and new.revoked_at is null then + perform 1 from public.work_hospital_teams where service_id = new.service_id and hospital_id = new.hospital_id for share; + if not found then raise exception 'work_team_changed'; end if; + end if; + return new; +end $$; +alter function public.work_guard_whole_team_supervisor() owner to postgres; +revoke all on function public.work_guard_whole_team_supervisor() from public, anon, authenticated; +grant execute on function public.work_guard_whole_team_supervisor() to service_role; +create trigger work_whole_team_supervisor_scope before insert or update on public.work_role_grants +for each row execute function public.work_guard_whole_team_supervisor(); + +-- Versioned application entry point: refuse app-before-SQL instead of moving +-- without the transactional revocation trigger. Old callers remain trigger-protected. +create function public.work_move_team_v1(p_actor_id uuid,p_service_id uuid,p_expected_hospital_id uuid,p_to_hospital_id uuid) +returns uuid language plpgsql security definer set search_path = '' as $$ +begin + if not exists(select 1 from auth.users where id = p_actor_id + and raw_app_meta_data->>'site_role' = 'administrator') then + raise exception 'work_role_denied'; + end if; + perform 1 from public.work_hospitals where id = p_to_hospital_id and archived_at is null for share; + if not found then raise exception 'work_hospital_unavailable'; end if; + if p_expected_hospital_id is not distinct from p_to_hospital_id then raise exception 'work_team_changed'; end if; + update public.work_hospital_teams set hospital_id = p_to_hospital_id, linked_by = p_actor_id, linked_at = now() + where service_id = p_service_id and hospital_id = p_expected_hospital_id; + if not found then raise exception 'work_team_changed'; end if; + return p_expected_hospital_id; +end $$; +alter function public.work_move_team_v1(uuid,uuid,uuid,uuid) owner to postgres; +revoke all on function public.work_move_team_v1(uuid,uuid,uuid,uuid) from public, anon, authenticated; +grant execute on function public.work_move_team_v1(uuid,uuid,uuid,uuid) to service_role; + +-- F14 / HY06: pairing authority is locked before authorization and child writes. +create or replace function public.teaching_depth_command(p_actor_id uuid, p_service_id uuid, p_action text, p_payload jsonb default '{}') returns jsonb +language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$ +declare + v_service public.on_call_services; + v_role text; + v_occ public.teaching_occurrences; + v_pairing public.teaching_supervision_pairings; + v_entry public.teaching_supervision_entries; + v_note public.teaching_supervision_notes; + v_readiness public.teaching_readiness; + v_id uuid; + v_registrar uuid; + v_supervisor uuid; + v_ids uuid[]; + v_topics text[]; + v_starts date; + v_ends date; + v_date date; + v_target numeric; + v_text text; + v_minutes integer; + v_useful integer; + v_count integer; + v_result jsonb; + v_row jsonb; + v_leaver boolean := false; +begin + if p_actor_id is null then raise exception 'teaching_auth_required'; end if; + if p_action is null or p_payload is null or jsonb_typeof(p_payload) <> 'object' or p_service_id is null + or p_action not in ('pairing.save','pairing.reassign','supervision.log','supervision.confirm','supervision.note', + 'supervision.note.confirm','supervision.read','supervision.target.set','readiness.set','readiness.deid.confirm', + 'feedback.submit','feedback.totals','import.commit') then + raise exception 'teaching_invalid_request'; + end if; + select * into v_service from public.on_call_services where id = p_service_id for share; + if not found then raise exception 'teaching_access_denied'; end if; + v_role := public.teaching_member_role(p_service_id, p_actor_id); + if v_role is null then + -- A leaver keeps read-only access to their own supervision entries (as registrar) for 90 days + -- from the shared revoke, as logbook.read keeps their attendance (spec §9). Nothing else. + v_leaver := p_action = 'supervision.read' and exists (select 1 from public.on_call_service_members m + where m.service_id = p_service_id and m.user_id = p_actor_id + and m.revoked_at is not null and m.revoked_at > now() - interval '90 days'); + if not v_leaver then raise exception 'teaching_access_denied'; end if; + end if; + if p_action not in ('supervision.read','feedback.totals') and not (v_service.verified_at is not null or v_service.is_demo) then + raise exception 'teaching_team_unverified'; + end if; + if p_action in ('pairing.save','pairing.reassign','import.commit') then + perform pg_advisory_xact_lock(hashtextextended(p_service_id::text, 74818)); + end if; + + if p_action = 'pairing.save' then + v_id := public.teaching_uuid_arg(p_payload, 'pairingId'); + v_registrar := public.teaching_uuid_arg(p_payload, 'registrarId'); + v_supervisor := public.teaching_uuid_arg(p_payload, 'supervisorId'); + v_starts := public.teaching_date_arg(p_payload, 'startsOn'); + v_ends := public.teaching_date_arg(p_payload, 'endsOn'); + -- The hours target is the registrar's own (supervision.target.set); an organiser never sets it. + if p_payload ? 'targetHours' then raise exception 'teaching_invalid_request'; end if; + -- Organisers pair other people. A pairing that includes the actor needs a Teaching admin. + if not (v_role = 'admin' or (v_role = 'organiser' and p_actor_id <> v_registrar and p_actor_id <> v_supervisor)) then + raise exception 'teaching_role_denied'; + end if; + if v_registrar is null or v_supervisor is null or v_registrar = v_supervisor + or v_starts is null or v_ends is null or v_ends < v_starts or v_ends > v_starts + 731 + or not public.service_member_active(p_service_id, v_registrar) + or not public.service_member_active(p_service_id, v_supervisor) then + raise exception 'teaching_invalid_request'; + end if; + if v_id is null then + if (select count(*) from public.teaching_supervision_pairings where service_id = p_service_id) >= 5000 then + raise exception 'teaching_limit'; + end if; + insert into public.teaching_supervision_pairings(service_id, registrar_id, supervisor_id, starts_on, ends_on, created_by) + values (p_service_id, v_registrar, v_supervisor, v_starts, v_ends, p_actor_id) + returning * into v_pairing; + else + select * into v_pairing from public.teaching_supervision_pairings where id = v_id and service_id = p_service_id for update; + if not found then raise exception 'teaching_not_found'; end if; + -- Once a pairing has entries, only its end date and target can change; the supervisor + -- changes through pairing.reassign. + if exists (select 1 from public.teaching_supervision_entries e where e.pairing_id = v_pairing.id) + and (v_pairing.registrar_id is distinct from v_registrar or v_pairing.supervisor_id is distinct from v_supervisor + or v_pairing.starts_on <> v_starts) then + raise exception 'teaching_invalid_request'; + end if; + update public.teaching_supervision_pairings set registrar_id = v_registrar, supervisor_id = v_supervisor, + starts_on = v_starts, ends_on = v_ends, updated_at = now() + where id = v_pairing.id returning * into v_pairing; + end if; + perform public.teaching_audit(p_service_id, p_actor_id, 'pairing.save', v_pairing.id); + return jsonb_build_object('pairingId', v_pairing.id); + + elsif p_action = 'pairing.reassign' then + v_id := public.teaching_uuid_arg(p_payload, 'pairingId'); + v_supervisor := public.teaching_uuid_arg(p_payload, 'supervisorId'); + select * into v_pairing from public.teaching_supervision_pairings where id = v_id and service_id = p_service_id for update; + if not found then raise exception 'teaching_not_found'; end if; + if not (v_role = 'admin' or (v_role = 'organiser' and p_actor_id is distinct from v_pairing.registrar_id and p_actor_id <> v_supervisor)) then + raise exception 'teaching_role_denied'; + end if; + if v_supervisor is null or v_supervisor is not distinct from v_pairing.registrar_id + or not public.service_member_active(p_service_id, v_supervisor) then + raise exception 'teaching_invalid_request'; + end if; + -- Confirmed entries keep who confirmed them; pending entries and notes now wait for the new + -- supervisor, because confirmation always checks the pairing's current supervisor. + update public.teaching_supervision_pairings set supervisor_id = v_supervisor, updated_at = now() where id = v_pairing.id; + select count(*) into v_count from public.teaching_supervision_entries where pairing_id = v_pairing.id and status = 'pending'; + perform public.teaching_audit(p_service_id, p_actor_id, 'pairing.reassign', v_pairing.id); + return jsonb_build_object('pairingId', v_pairing.id, 'pendingMoved', v_count); + + elsif p_action = 'supervision.target.set' then + -- The registrar's own hours target for this pairing: nullable, no default, and progress is shown + -- only once it is set. Only the pairing's registrar sets or clears it (targetHours null). + v_id := public.teaching_uuid_arg(p_payload, 'pairingId'); + select * into v_pairing from public.teaching_supervision_pairings where id = v_id and service_id = p_service_id for update; + if not found then raise exception 'teaching_not_found'; end if; + if v_pairing.registrar_id is distinct from p_actor_id then raise exception 'teaching_role_denied'; end if; + if not (p_payload ? 'targetHours') then raise exception 'teaching_invalid_request'; end if; + if p_payload->'targetHours' <> 'null'::jsonb then + if jsonb_typeof(p_payload->'targetHours') <> 'number' or (p_payload->>'targetHours') !~ '^[0-9]{1,3}(\.[0-9])?$' then + raise exception 'teaching_invalid_request'; + end if; + v_target := (p_payload->>'targetHours')::numeric; + if v_target not between 1 and 500 then raise exception 'teaching_invalid_request'; end if; + end if; + update public.teaching_supervision_pairings set target_hours = v_target, updated_at = now() where id = v_pairing.id; + perform public.teaching_audit(p_service_id, p_actor_id, 'supervision.target.set', v_pairing.id); + return jsonb_build_object('pairingId', v_pairing.id, 'targetHours', v_target); + + elsif p_action = 'supervision.log' then + v_id := public.teaching_uuid_arg(p_payload, 'pairingId'); + select * into v_pairing from public.teaching_supervision_pairings where id = v_id and service_id = p_service_id for update; + if not found then raise exception 'teaching_not_found'; end if; + if v_pairing.registrar_id is distinct from p_actor_id then raise exception 'teaching_role_denied'; end if; + v_date := public.teaching_date_arg(p_payload, 'date'); + v_minutes := public.teaching_int_arg(p_payload, 'minutes'); + v_text := public.teaching_text_arg(p_payload, 'type'); + v_topics := public.teaching_text_array_arg(p_payload, 'topics', 5); + if v_date is null or v_date < v_pairing.starts_on or v_date > v_pairing.ends_on + or v_date > (now() at time zone 'Australia/Perth')::date + or v_minutes is null or v_minutes not between 15 and 240 or v_minutes % 15 <> 0 + or v_text is null or v_text not in ('individual','group') + -- Topics are optional: none to five. + or cardinality(v_topics) not between 0 and 5 + or not (v_topics <@ array['case_review','risk','psychotherapy','formulation','medication','mha_legal','teaching_skills','career','exam_prep','wellbeing','other']::text[]) then + raise exception 'teaching_invalid_request'; + end if; + if (select count(*) from public.teaching_supervision_entries where pairing_id = v_pairing.id) >= 1000 then + raise exception 'teaching_limit'; + end if; + insert into public.teaching_supervision_entries(pairing_id, service_id, session_date, minutes, type, topics, logged_by) + values (v_pairing.id, p_service_id, v_date, v_minutes, v_text, v_topics, p_actor_id) + returning * into v_entry; + perform public.teaching_audit(p_service_id, p_actor_id, 'supervision.log', v_entry.id); + return jsonb_build_object('entryId', v_entry.id); + + elsif p_action = 'supervision.confirm' then + v_ids := public.teaching_uuid_array_arg(p_payload, 'entryIds', 20); + if cardinality(v_ids) = 0 then raise exception 'teaching_invalid_request'; end if; + -- Parent before child, sorted for multi-pairing batches. Authorization is checked + -- after any wait, so confirmation linearizes with reassignment; a confirmation + -- committed first remains historical evidence under its original supervisor. + perform p.id from public.teaching_supervision_pairings p + where p.service_id = p_service_id and p.id in ( + select e.pairing_id from public.teaching_supervision_entries e + where e.id = any(v_ids) and e.service_id = p_service_id) + order by p.id for update; + perform e.id from public.teaching_supervision_entries e + where e.id = any(v_ids) and e.service_id = p_service_id order by e.id for update; + -- All or nothing: every entry must be pending and belong to a pairing the actor now supervises. + if (select count(*) from public.teaching_supervision_entries e + join public.teaching_supervision_pairings p on p.id = e.pairing_id + where e.id = any(v_ids) and e.service_id = p_service_id and e.status = 'pending' and p.supervisor_id = p_actor_id) + <> cardinality(v_ids) then + raise exception 'teaching_not_found'; + end if; + update public.teaching_supervision_entries set status = 'confirmed', confirmed_by = p_actor_id, confirmed_at = now(), locked = true + where id = any(v_ids); + insert into public.teaching_audit_events(service_id, actor_id, action, subject_id) + select p_service_id, p_actor_id, 'supervision.confirm', u from unnest(v_ids) as u; + return jsonb_build_object('confirmed', cardinality(v_ids)); + + elsif p_action = 'supervision.note' then + v_id := public.teaching_uuid_arg(p_payload, 'entryId'); + perform p.id from public.teaching_supervision_pairings p + join public.teaching_supervision_entries e on e.pairing_id = p.id + where e.id = v_id and e.service_id = p_service_id for update of p; + select e.* into v_entry from public.teaching_supervision_entries e + join public.teaching_supervision_pairings p on p.id = e.pairing_id + where e.id = v_id and e.service_id = p_service_id and p.registrar_id = p_actor_id; + if not found then raise exception 'teaching_not_found'; end if; + v_text := public.teaching_text_arg(p_payload, 'reason'); + if v_text is null or not public.teaching_valid_correction(v_text, p_payload->'correctedValue') then + raise exception 'teaching_invalid_request'; + end if; + if v_text = 'wrong_date' then + perform public.teaching_date_arg(p_payload->'correctedValue', 'date'); + end if; + if (select count(*) from public.teaching_supervision_notes where entry_id = v_entry.id and confirmed_at is null) >= 5 then + raise exception 'teaching_limit'; + end if; + insert into public.teaching_supervision_notes(entry_id, service_id, reason, corrected_value, created_by) + values (v_entry.id, p_service_id, v_text, p_payload->'correctedValue', p_actor_id) + returning * into v_note; + perform public.teaching_audit(p_service_id, p_actor_id, 'supervision.note', v_note.id); + return jsonb_build_object('noteId', v_note.id); + + elsif p_action = 'supervision.note.confirm' then + v_id := public.teaching_uuid_arg(p_payload, 'noteId'); + perform p.id from public.teaching_supervision_pairings p + join public.teaching_supervision_entries e on e.pairing_id = p.id + join public.teaching_supervision_notes n on n.entry_id = e.id + where n.id = v_id and n.service_id = p_service_id for update of p; + select n.* into v_note from public.teaching_supervision_notes n + join public.teaching_supervision_entries e on e.id = n.entry_id + join public.teaching_supervision_pairings p on p.id = e.pairing_id + where n.id = v_id and n.service_id = p_service_id and p.supervisor_id = p_actor_id and n.confirmed_at is null + for update of n; + if not found then raise exception 'teaching_not_found'; end if; + update public.teaching_supervision_notes set confirmed_by = p_actor_id, confirmed_at = now() where id = v_note.id; + perform public.teaching_audit(p_service_id, p_actor_id, 'supervision.note.confirm', v_note.id); + return jsonb_build_object('noteId', v_note.id); + + elsif p_action = 'supervision.read' then + -- The registrar and supervisor of a pairing see its entries; an organiser sees totals and + -- status only (never topics). Reading anyone else's pairing is audited. A leaver (v_leaver) + -- sees only the pairings where they are the registrar, and reads their own record unaudited. + v_id := public.teaching_uuid_arg(p_payload, 'pairingId'); + if v_id is not null and not exists ( + select 1 from public.teaching_supervision_pairings p where p.id = v_id and p.service_id = p_service_id + and (p.registrar_id = p_actor_id or (not v_leaver and (p.supervisor_id = p_actor_id or v_role = 'organiser')))) then + raise exception 'teaching_not_found'; + end if; + if v_id is null and v_role = 'organiser' then + -- An organiser's whole-team read (Logbook, Today): one service-level row per call. + perform public.teaching_audit(p_service_id, p_actor_id, 'supervision.read', null); + elsif not v_leaver then + insert into public.teaching_audit_events(service_id, actor_id, action, subject_id) + select p_service_id, p_actor_id, 'supervision.read', p.id + from public.teaching_supervision_pairings p + where p.service_id = p_service_id and (v_id is null or p.id = v_id) + and p.registrar_id is distinct from p_actor_id + and (p.supervisor_id = p_actor_id or v_role = 'organiser'); + end if; + select jsonb_build_object('pairings', coalesce(jsonb_agg(jsonb_build_object( + 'pairingId', p.id, + 'access', case when p.registrar_id = p_actor_id then 'registrar' when p.supervisor_id = p_actor_id then 'supervisor' else 'organiser' end, + 'registrarName', case when p.registrar_id is null then 'Former member' else coalesce((select coalesce(m.display_name, 'Member') + from public.on_call_service_members m where m.service_id = p.service_id and m.user_id = p.registrar_id), 'Former member') end, + 'supervisorName', case when p.supervisor_id is null then 'Former member' else coalesce((select coalesce(m.display_name, 'Member') + from public.on_call_service_members m where m.service_id = p.service_id and m.user_id = p.supervisor_id), 'Former member') end, + 'startsOn', p.starts_on, 'endsOn', p.ends_on, + 'targetHours', case when p.registrar_id = p_actor_id or p.supervisor_id = p_actor_id then p.target_hours else null end, + 'confirmedMinutes', (select coalesce(sum(e.minutes), 0) from public.teaching_supervision_entries e where e.pairing_id = p.id and e.status = 'confirmed'), + 'pendingMinutes', (select coalesce(sum(e.minutes), 0) from public.teaching_supervision_entries e where e.pairing_id = p.id and e.status = 'pending'), + 'pendingCount', (select count(*) from public.teaching_supervision_entries e where e.pairing_id = p.id and e.status = 'pending'), + 'oldestPendingAt', (select min(e.created_at) from public.teaching_supervision_entries e where e.pairing_id = p.id and e.status = 'pending'), + 'entries', case when p.registrar_id = p_actor_id or p.supervisor_id = p_actor_id then coalesce((select jsonb_agg(jsonb_build_object( + 'entryId', e.id, 'date', e.session_date, 'minutes', e.minutes, 'type', e.type, 'topics', to_jsonb(e.topics), + 'status', e.status, 'confirmedAt', e.confirmed_at, + 'confirmedByName', case when e.confirmed_at is null then null when e.confirmed_by is null then 'Former member' else coalesce( + (select coalesce(m.display_name, 'Member') from public.on_call_service_members m where m.service_id = e.service_id and m.user_id = e.confirmed_by), + 'Former member') end, + 'notes', coalesce((select jsonb_agg(jsonb_build_object('noteId', n.id, 'reason', n.reason, 'correctedValue', n.corrected_value, + 'createdAt', n.created_at, 'confirmedAt', n.confirmed_at) order by n.created_at) + from public.teaching_supervision_notes n where n.entry_id = e.id), '[]'::jsonb) + ) order by e.session_date desc, e.created_at desc) from public.teaching_supervision_entries e where e.pairing_id = p.id), '[]'::jsonb) + else null end + ) order by p.starts_on desc, p.id), '[]'::jsonb)) + into v_result + from public.teaching_supervision_pairings p + where p.service_id = p_service_id and (v_id is null or p.id = v_id) + and (p.registrar_id = p_actor_id or (not v_leaver and (p.supervisor_id = p_actor_id or v_role = 'organiser'))); + return v_result; + + elsif p_action in ('readiness.set','readiness.deid.confirm') then + v_id := public.teaching_uuid_arg(p_payload, 'occurrenceId'); + select * into v_occ from public.teaching_occurrences where id = v_id and service_id = p_service_id; + if not found then raise exception 'teaching_not_found'; end if; + if v_occ.presenter_id is distinct from p_actor_id then raise exception 'teaching_role_denied'; end if; + insert into public.teaching_readiness(occurrence_id, service_id) values (v_occ.id, p_service_id) + on conflict (occurrence_id) do nothing; + if p_action = 'readiness.set' then + v_text := public.teaching_text_arg(p_payload, 'item'); + if v_text is null or v_text not in ('reading_list','aims','slides_link','room') + or jsonb_typeof(p_payload->'done') is distinct from 'boolean' then + raise exception 'teaching_invalid_request'; + end if; + update public.teaching_readiness set + items = case when (p_payload->>'done')::boolean + then array(select distinct i from unnest(array_append(items, v_text)) as i order by i) + else array_remove(items, v_text) end, + updated_at = now() + where occurrence_id = v_occ.id returning * into v_readiness; + else + update public.teaching_readiness set deid_confirmed_at = now(), deid_confirmed_by = p_actor_id, updated_at = now() + where occurrence_id = v_occ.id returning * into v_readiness; + end if; + perform public.teaching_audit(p_service_id, p_actor_id, p_action, v_occ.id); + return jsonb_build_object('items', to_jsonb(v_readiness.items), 'deidConfirmedAt', v_readiness.deid_confirmed_at); + + elsif p_action = 'feedback.submit' then + v_id := public.teaching_uuid_arg(p_payload, 'occurrenceId'); + select * into v_occ from public.teaching_occurrences where id = v_id and service_id = p_service_id; + if not found then raise exception 'teaching_not_found'; end if; + if not exists (select 1 from public.teaching_attendance a where a.occurrence_id = v_occ.id and a.user_id = p_actor_id) then + raise exception 'teaching_not_attended'; + end if; + if v_occ.status = 'cancelled' or now() < v_occ.ends_at or now() > v_occ.ends_at + interval '7 days' then + raise exception 'teaching_window_closed'; + end if; + v_useful := public.teaching_int_arg(p_payload, 'useful'); + v_text := public.teaching_text_arg(p_payload, 'pace'); + if v_useful is null or v_useful not between 1 and 5 or v_text is null or v_text not in ('slow','right','fast') then + raise exception 'teaching_invalid_request'; + end if; + -- One-way "already answered" marker. No responder id is stored with feedback anywhere. + insert into public.teaching_feedback_replied(occurrence_id, reply_marker) + values (v_occ.id, encode(extensions.digest(convert_to('teaching-feedback:' || v_occ.id::text || ':' || p_actor_id::text, 'UTF8'), 'sha256'), 'hex')) + on conflict (occurrence_id, reply_marker) do nothing; + if not found then raise exception 'teaching_limit'; end if; + insert into public.teaching_feedback_answers(occurrence_id, service_id, useful, pace) + values (v_occ.id, p_service_id, v_useful, v_text); + -- No actor on this audit row: nothing may tie a person to the moment an answer was written. + perform public.teaching_audit(p_service_id, null, 'feedback.submit', v_occ.id); + return '{}'::jsonb; + + elsif p_action = 'import.commit' then + -- A term from a spreadsheet, all or nothing: every row is a new series through the same save as + -- series.save, inside this one transaction, so one refused row rolls back every row before it. + -- Spec §4 gives import to organisers and admins. + if v_role not in ('organiser','admin') then raise exception 'teaching_role_denied'; end if; + if jsonb_typeof(p_payload->'rows') is distinct from 'array' + or jsonb_array_length(p_payload->'rows') not between 1 and 200 then + raise exception 'teaching_invalid_request'; + end if; + v_count := 0; + for v_row in select r.value from jsonb_array_elements(p_payload->'rows') as r(value) loop + if jsonb_typeof(v_row) is distinct from 'object' or v_row ? 'seriesId' then + raise exception 'teaching_invalid_request'; + end if; + v_result := public.teaching_series_save(p_actor_id, p_service_id, v_row); + perform public.teaching_audit(p_service_id, p_actor_id, 'series.save', public.teaching_uuid_arg(v_result, 'seriesId')); + v_count := v_count + public.teaching_int_arg(v_result, 'occurrences'); + end loop; + perform public.teaching_audit(p_service_id, p_actor_id, 'import.commit', null); + return jsonb_build_object('series', jsonb_array_length(p_payload->'rows'), 'occurrences', v_count); + + elsif p_action = 'feedback.totals' then + v_id := public.teaching_uuid_arg(p_payload, 'occurrenceId'); + select * into v_occ from public.teaching_occurrences where id = v_id and service_id = p_service_id; + if not found then raise exception 'teaching_not_found'; end if; + if (v_occ.presenter_id = p_actor_id or v_role = 'organiser') is not true then raise exception 'teaching_role_denied'; end if; + select count(*) into v_count from public.teaching_feedback_answers where occurrence_id = v_occ.id; + if now() < v_occ.ends_at + interval '7 days' or v_count < 5 then + return jsonb_build_object('released', false); + end if; + perform public.teaching_audit(p_service_id, p_actor_id, 'feedback.totals', v_occ.id); + return jsonb_build_object( + 'released', true, + 'replies', v_count, + 'useful', (select jsonb_build_object('1', count(*) filter (where useful = 1), '2', count(*) filter (where useful = 2), + '3', count(*) filter (where useful = 3), '4', count(*) filter (where useful = 4), '5', count(*) filter (where useful = 5)) + from public.teaching_feedback_answers where occurrence_id = v_occ.id), + 'pace', (select jsonb_build_object('slow', count(*) filter (where pace = 'slow'), 'right', count(*) filter (where pace = 'right'), + 'fast', count(*) filter (where pace = 'fast')) + from public.teaching_feedback_answers where occurrence_id = v_occ.id) + ); + end if; + + raise exception 'teaching_invalid_request'; +end $$; +alter function public.teaching_depth_command(uuid,uuid,text,jsonb) owner to postgres; +revoke all on function public.teaching_depth_command(uuid,uuid,text,jsonb) from public, anon, authenticated; +grant execute on function public.teaching_depth_command(uuid,uuid,text,jsonb) to service_role; diff --git a/tests/account-request-scope.dom.test.tsx b/tests/account-request-scope.dom.test.tsx new file mode 100644 index 0000000000..d316040790 --- /dev/null +++ b/tests/account-request-scope.dom.test.tsx @@ -0,0 +1,121 @@ +/** @vitest-environment jsdom */ + +import { act, renderHook } from "@testing-library/react"; +import { useEffect } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useAccountRequestScope } from "@/components/use-account-request-scope"; +import { createAuthRequestLifecycle } from "@/lib/auth-request-lifecycle"; +import type { useAuthSession } from "@/lib/supabase/client"; + +type Auth = ReturnType; +const state = vi.hoisted(() => ({ auth: null as Auth | null })); +vi.mock("@/lib/supabase/client", () => ({ useAuthSession: () => state.auth })); + +let lifecycle: ReturnType; + +beforeEach(() => { + lifecycle = createAuthRequestLifecycle(7); + // Keep every field of the real AuthProvider contract, including its stable, + // imperative epoch methods. No provider, cookie or network request is used. + state.auth = { + client: null, + session: { + access_token: "synthetic-token-1", + refresh_token: "synthetic-refresh", + expires_in: 3600, + token_type: "bearer", + user: { + id: "synthetic-A", + app_metadata: {}, + user_metadata: {}, + aud: "authenticated", + created_at: "2020-01-01T00:00:00Z", + }, + }, + status: "authenticated", + error: null, + notice: null, + isConfigured: true, + authorizationHeader: { Authorization: "Bearer synthetic-token-1" }, + authEpoch: lifecycle.currentEpoch(), + registerAuthRequest: lifecycle.register, + isAuthEpochCurrent: lifecycle.isCurrent, + signInWithEmail: vi.fn(async () => undefined), + signInWithPassword: vi.fn(async () => undefined), + signUpWithPassword: vi.fn(async () => undefined), + signInWithOAuth: vi.fn(async () => undefined), + signOut: vi.fn(async () => undefined), + markSessionExpired: vi.fn(), + } satisfies Auth; +}); + +describe("account request scope with AuthProvider's epoch contract", () => { + it("rejects captured work when the real lifecycle invalidates before React publishes another auth value", () => { + const view = renderHook(useAccountRequestScope); + const captured = view.result.current.isCurrent; + const publishedAuth = state.auth!; + const controller = new AbortController(); + const registration = publishedAuth.registerAuthRequest(controller); + expect(captured()).toBe(true); + + // AuthProvider invalidates imperatively before its queued React render. + // Deliberately do not mutate auth fields, replace the object or rerender. + lifecycle.invalidate(); + expect(state.auth).toBe(publishedAuth); + expect(state.auth!.authEpoch).toBe(7); + expect(controller.signal.aborted).toBe(true); + expect(captured()).toBe(false); + registration.release(); + }); + + it("rejects retained work after unmount even while the auth epoch is still current", () => { + const view = renderHook(useAccountRequestScope); + const captured = view.result.current.isCurrent; + expect(captured()).toBe(true); + view.unmount(); + expect(state.auth!.isAuthEpochCurrent(state.auth!.authEpoch)).toBe(true); + expect(captured()).toBe(false); + }); + + it("preserves the scope, dependent read and queued callback across same-owner token refresh", async () => { + const dependentRead = vi.fn(); + const queuedWrite = vi.fn(); + const view = renderHook(() => { + const scope = useAccountRequestScope(); + useEffect(() => { + dependentRead(); + }, [scope.isCurrent]); + return scope; + }); + const captured = view.result.current.isCurrent; + const scope = view.result.current.scope; + let release!: () => void; + const barrier = new Promise((resolve) => { + release = resolve; + }); + const queued = barrier.then(() => { + if (captured()) queuedWrite(); + }); + + const previous = state.auth!; + act(() => { + state.auth = { + ...previous, + authorizationHeader: { Authorization: "Bearer synthetic-token-2" }, + session: { ...previous.session!, access_token: "synthetic-token-2" }, + }; + view.rerender(); + }); + expect(state.auth).not.toBe(previous); + expect(view.result.current.scope).toBe(scope); + expect(view.result.current.isCurrent).toBe(captured); + expect(captured()).toBe(true); + expect(dependentRead).toHaveBeenCalledTimes(1); + await act(async () => { + release(); + await queued; + }); + expect(queuedWrite).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/admin-auth-transition.dom.test.tsx b/tests/admin-auth-transition.dom.test.tsx new file mode 100644 index 0000000000..4669b1a20c --- /dev/null +++ b/tests/admin-auth-transition.dom.test.tsx @@ -0,0 +1,95 @@ +/** @vitest-environment jsdom */ +// Existing B, mounted components and immutable auth values. All requests are synthetic. +import { act, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { AdminNewJobSharing } from "@/components/admin/new-job/admin-new-job-sharing"; +import { useJuniorRosterLeave } from "@/components/admin/junior/use-roster-leave"; + +const state = vi.hoisted(() => ({ + auth: { status: "authenticated", authEpoch: 1, session: { user: { id: "synthetic-A" } } }, +})); +vi.mock("@/lib/supabase/client", () => ({ useAuthSession: () => state.auth })); +function switchAccount() { + state.auth = { ...state.auth, authEpoch: state.auth.authEpoch + 1, session: { user: { id: "synthetic-B" } } }; +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +beforeEach(() => { + state.auth = { ...state.auth, authEpoch: state.auth.authEpoch + 1, session: { user: { id: "synthetic-A" } } }; +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("mounted Admin identity consumers", () => { + it("loads B leave instead of retaining A's approved absence", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ + leave: + state.auth.session.user.id === "synthetic-A" + ? [ + { + id: "synthetic-A-leave", + kind: "annual", + startsOn: "2026-11-01", + endsOn: "2026-11-03", + status: "approved", + }, + ] + : [], + }), + ), + ); + const view = renderHook(() => useJuniorRosterLeave(true)); + await waitFor(() => expect(view.result.current.status).toBe("ready")); + expect(view.result.current).toMatchObject({ leave: [{ id: "synthetic-A-leave" }] }); + act(() => { + switchAccount(); + view.rerender(); + }); + await waitFor(() => expect(view.result.current).toEqual({ status: "ready", leave: [] })); + }); + + it("reads B's sharing choice when the mounted account changes", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ share: state.auth.session.user.id === "synthetic-A" })), + ); + const view = render(); + await waitFor(() => expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "true")); + act(() => { + switchAccount(); + view.rerender(); + }); + await waitFor(() => expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "false")); + }); + + for (const status of [200, 500]) { + it(`drops A's delayed sharing save result (${status}) after B loads`, async () => { + const pending = deferred(); + vi.stubGlobal( + "fetch", + vi.fn((_input: unknown, init?: RequestInit) => + init?.method === "PUT" ? pending.promise : Promise.resolve(Response.json({ share: false })), + ), + ); + const view = render(); + fireEvent.click(await screen.findByRole("switch")); + act(() => { + switchAccount(); + view.rerender(); + }); + await act(async () => {}); + await act(async () => pending.resolve(Response.json({ share: true }, { status }))); + expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "false"); + expect(screen.getByRole("switch")).not.toBeDisabled(); + expect(screen.queryByRole("alert")).toBeNull(); + }); + } +}); diff --git a/tests/admin-new-job-page.dom.test.tsx b/tests/admin-new-job-page.dom.test.tsx index 402577578b..e75013620b 100644 --- a/tests/admin-new-job-page.dom.test.tsx +++ b/tests/admin-new-job-page.dom.test.tsx @@ -1,6 +1,6 @@ /** @vitest-environment jsdom */ -import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AdminNewJobPage } from "@/components/admin/admin-new-job-page"; @@ -65,6 +65,11 @@ const entryState = vi.hoisted(() => ({ retry: vi.fn(), })); +const identity = vi.hoisted(() => ({ + auth: { status: "authenticated", authEpoch: 1, session: { user: { id: "synthetic-A" } } }, +})); +vi.mock("@/lib/supabase/client", () => ({ useAuthSession: () => identity.auth })); + const cacheOnCallEntries = vi.fn(); vi.mock("@/lib/on-call/entry-store", () => ({ useOnCallEntries: () => entryState, @@ -344,3 +349,33 @@ describe("AdminNewJobPage credentials wallet", () => { expect(screen.queryByTestId("admin-credentials-wallet")).toBeNull(); }); }); + +describe("New job captured-account writes", () => { + for (const status of [200, 500]) { + it(`drops A PATCH completion (${status}) after the entry store resets for B`, async () => { + let resolve!: (response: Response) => void; + vi.spyOn(globalThis, "fetch").mockImplementation( + () => + new Promise((done) => { + resolve = done; + }), + ); + const view = render(); + fireEvent.click(screen.getByTestId(`admin-new-job-step-${loginOwn.slug}-checkbox`)); + // AuthProvider clears the real store before publishing the next immutable identity. + identity.auth = { + ...identity.auth, + authEpoch: identity.auth.authEpoch + 1, + session: { user: { id: "synthetic-B" } }, + }; + Object.assign(entryState, { entries: [], loading: false }); + view.rerender(); + await act(async () => + resolve(Response.json({ entry: { ...loginOwn, details: { category: "Logins", done: true } } }, { status })), + ); + expect(cacheOnCallEntries).not.toHaveBeenCalled(); + expect(screen.queryByTestId("admin-new-job-undo")).toBeNull(); + expect(screen.queryByTestId("admin-new-job-error")).toBeNull(); + }); + } +}); diff --git a/tests/admin-pay-history.dom.test.tsx b/tests/admin-pay-history.dom.test.tsx new file mode 100644 index 0000000000..fe96f4e66d --- /dev/null +++ b/tests/admin-pay-history.dom.test.tsx @@ -0,0 +1,176 @@ +/** @vitest-environment jsdom */ +// Regression from the audit: real component/hooks, intercepted APIs with the 21-day cutoff. +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +vi.mock("next/navigation", () => ({ + usePathname: () => "/admin/pay", + useSearchParams: () => new URLSearchParams(), + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), prefetch: vi.fn(), refresh: vi.fn() }), +})); +vi.mock("@/lib/supabase/client", () => ({ + useAuthSession: () => ({ + status: "authenticated", + authEpoch: 1, + session: { user: { id: "synthetic-A", created_at: "2020-01-01T00:00:00Z" } }, + }), + hasSupabaseAuthSession: () => true, +})); +vi.mock("@/components/account-data-provider", () => ({ + useAccountData: () => ({ isAuthenticated: true }), + useOptionalAccountData: () => ({ isAuthenticated: true }), +})); +vi.mock("@/lib/on-call/entry-store", async (importOriginal) => ({ + ...(await importOriginal()), + useOnCallEntries: () => ({ + entries: [], + loading: false, + isOffline: false, + loadError: null, + signedOut: false, + demoMode: false, + cachedAt: null, + retry: () => undefined, + }), +})); +import { AdminPayPage } from "@/components/work-screens/admin/pay-page"; +import { resetSharedGets } from "@/lib/shared-get"; +import { resetExampleDataForTests, setExampleDataOn } from "@/lib/example-data/store"; +import { forgetAdminPaperworkOnDevice } from "@/lib/work-screens/admin/paperwork-store"; +import { addDaysToDate } from "@/lib/roster/shifts/perth-time"; +const now = new Date("2026-10-17T01:00:00Z"); +const allShifts = ["2026-09-22", "2026-09-28"].map((date, index) => ({ + id: `synthetic-pay-${index}`, + startsAt: `${date}T00:00:00Z`, + endsAt: `${date}T08:00:00Z`, + title: "Synthetic shift", + location: null, + sourceUid: null, + kind: "day", + source: "import", + seriesId: null, + workplace: "Synthetic workplace", +})); +beforeEach(() => { + forgetAdminPaperworkOnDevice(); + window.localStorage.clear(); + resetExampleDataForTests(); + setExampleDataOn(false); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(now); + resetSharedGets(); +}); +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + resetSharedGets(); +}); +const teamId = "51000000-0000-4000-8000-000000000001"; + +function serve({ + anchor = null, + empty = false, + fail = () => false, + shifts = allShifts, +}: { + anchor?: string | null; + empty?: boolean; + fail?: () => boolean; + shifts?: typeof allShifts; +} = {}) { + const calls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(String(input), "http://offline.invalid"); + calls.push(url.pathname + url.search); + if (url.pathname === "/api/roster/shifts") { + const from = url.searchParams.get("from"); + if (from && fail()) return Response.json({ error: "temporarily unavailable" }, { status: 503 }); + const cutoff = from ? Date.parse(`${from}T00:00:00Z`) : now.getTime() - 21 * 24 * 60 * 60 * 1000; + const to = url.searchParams.get("to"); + const end = to ? Date.parse(`${addDaysToDate(to, 1)}T00:00:00Z`) : Infinity; + return Response.json({ + shifts: empty + ? [] + : shifts.filter((shift) => Date.parse(shift.endsAt) > cutoff && Date.parse(shift.startsAt) < end), + latestImport: null, + }); + } + if (url.pathname === "/api/roster/team") + return Response.json({ + teams: anchor + ? [{ serviceId: teamId, name: "Synthetic team", enabled: true, role: "member", grade: "registrar" }] + : [], + actorId: "synthetic-A", + }); + if (url.pathname === `/api/roster/team/${teamId}`) + return Response.json( + url.searchParams.get("what") === "overview" + ? { + service: { id: teamId, name: "Synthetic team" }, + me: { role: "member", grade: "registrar", rotationEndsOn: null }, + latestPublication: null, + seenLatest: true, + sites: [], + settings: { swapApproval: "auto_same_grade", rules: {}, rulesSource: null, payFortnightAnchor: anchor }, + } + : { assignments: [] }, + ); + if (url.pathname === "/api/roster/extra-time") return Response.json({ records: [] }); + return Response.json({}); + }), + ); + return calls; +} + +it.each([ + { anchor: null, from: "2026-09-21", to: "2026-10-18", expected: "16", empty: false }, + { anchor: "2026-10-07", from: "2026-09-23", to: "2026-10-20", expected: "8", empty: false }, + { anchor: null, from: "2026-09-21", to: "2026-10-18", expected: "0", empty: true }, +])( + "fills the entire preceding fortnight with $expected hours (anchor $anchor, empty $empty)", + async ({ anchor, from, to, expected, empty }) => { + const calls = serve({ anchor, empty }); + render(); + await screen.findByTestId("admin-pay-hero"); + fireEvent.click(screen.getByTestId("admin-pay-check")); + // Default is Sep21-Oct4. Both known shifts are within it (16h); main GET only supplies Sep28 (8h). + await waitFor(() => expect(screen.getByTestId("admin-pay-rostered")).toHaveValue(expected)); + const readFrom = addDaysToDate(from, -1); + expect(calls.some((url) => url.includes(`/api/roster/shifts?from=${readFrom}`))).toBe(true); + if (anchor) expect(calls.some((url) => url.includes(`from=${readFrom}&to=${to}`))).toBe(true); + }, +); + +it("includes a midnight shift on the first local fortnight day without counting padded outside days or overlap twice", async () => { + const shift = (id: string, startsAt: string, endsAt: string) => ({ ...allShifts[0]!, id, startsAt, endsAt }); + serve({ + shifts: [ + // Previous fortnight starts21Sep00:00 AWST. Its first8h end exactly at the old UTC lower bound. + shift("first-local-day", "2026-09-20T16:00:00Z", "2026-09-21T00:00:00Z"), + // Padding may fetch this preceding-local-day shift, but it contributes no period hours. + shift("outside-period", "2026-09-20T00:00:00Z", "2026-09-20T08:00:00Z"), + // Both default and historical reads contain this row; count its8h once. + shift("overlap", "2026-09-26T08:00:00Z", "2026-09-26T16:00:00Z"), + ], + }); + render(); + await screen.findByTestId("admin-pay-hero"); + fireEvent.click(screen.getByTestId("admin-pay-check")); + await waitFor(() => expect(screen.getByTestId("admin-pay-rostered")).toHaveValue("16")); +}); + +it("does not prefill a partial total when older days fail, and recovers on retry", async () => { + let fail = true; + serve({ fail: () => fail }); + render(); + const warning = await screen.findByTestId("admin-pay-roster-failed"); + expect(warning).toHaveTextContent("Couldn't load your roster"); + expect(screen.queryByTestId("admin-pay-hero")).toBeNull(); + fail = false; + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + await screen.findByTestId("admin-pay-hero"); + fireEvent.click(screen.getByTestId("admin-pay-check")); + await waitFor(() => expect(screen.getByTestId("admin-pay-rostered")).toHaveValue("16")); +}); diff --git a/tests/agent-monitoring.test.ts b/tests/agent-monitoring.test.ts index 8dfe47c0e7..cdda0f80f0 100644 --- a/tests/agent-monitoring.test.ts +++ b/tests/agent-monitoring.test.ts @@ -1,8 +1,12 @@ +import { createRequire } from "node:module"; +import path from "node:path"; + import { afterEach, describe, expect, it, vi } from "vitest"; describe("Sentry agent monitoring gating", () => { afterEach(() => { vi.unstubAllEnvs(); + vi.doUnmock("@sentry/nextjs"); vi.resetModules(); }); @@ -37,3 +41,75 @@ describe("Sentry agent monitoring gating", () => { expect(() => setAgentConversationId("11111111-2222-4333-8444-555555555555")).not.toThrow(); }); }); + +// Load the installed package's actual conditional Edge entry: its export set +// differs from Node, so a whole-SDK mock could conceal this compatibility bug. +const require = createRequire(import.meta.url); +const sdkPackagePath = require.resolve("@sentry/nextjs/package.json"); +const sdkPackage = require(sdkPackagePath) as { exports: { ".": { edge: { require: string } } } }; +const edgeSdk = require( + path.resolve(path.dirname(sdkPackagePath), sdkPackage.exports["."].edge.require), +) as typeof import("@sentry/nextjs"); + +describe("agent conversation binding against installed SDK exports", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.doUnmock("@sentry/nextjs"); + vi.resetModules(); + }); + + async function enabledEdgeModule() { + vi.stubEnv("SENTRY_DSN", "https://synthetic@127.0.0.1/1"); + vi.stubEnv("SENTRY_TRACES_SAMPLE_RATE", "0.1"); + vi.doMock("@sentry/nextjs", () => edgeSdk); + return import("@/lib/observability/agent-monitoring"); + } + + // getClient is only used as an initialized-runtime gate by our wrapper. + // Bind an inert client object inside a temporary scope instead of Sentry.init: + // no integrations, DSN parsing, transport creation or delivery can start. + function withInertClient(callback: () => void) { + const isolationScope = edgeSdk.getIsolationScope(); + const previousConversation = isolationScope.getScopeData().conversationId; + isolationScope.setConversationId(undefined); + try { + edgeSdk.withScope((scope) => { + scope.setClient({} as NonNullable>); + callback(); + }); + } finally { + // Without init there is no runtime async-context isolation strategy. + isolationScope.setConversationId(previousConversation); + } + } + + it("binds the UUID through the real isolation-scope API when the runtime omits the convenience export", async () => { + const { isSentryAgentMonitoringEnabled, setAgentConversationId } = await enabledEdgeModule(); + const conversationId = "11111111-2222-4333-8444-555555555555"; + expect(edgeSdk.getClient()).toBeUndefined(); + expect(typeof edgeSdk.getIsolationScope().setConversationId).toBe("function"); + withInertClient(() => { + expect(isSentryAgentMonitoringEnabled()).toBe(true); + setAgentConversationId(conversationId); + expect(edgeSdk.getIsolationScope().getScopeData().conversationId).toBe(conversationId); + }); + expect(edgeSdk.getClient()).toBeUndefined(); + expect(edgeSdk.getIsolationScope().getScopeData().conversationId).toBeUndefined(); + }); + + it("rejects free text and malformed identifiers before touching the actual SDK scope", async () => { + const { setAgentConversationId } = await enabledEdgeModule(); + withInertClient(() => { + const scope = edgeSdk.getIsolationScope(); + for (const invalid of [ + "Synthetic clinical query", + "person@example.test", + "x".repeat(36), + "11111111-2222-4333-8444-555555555555 suffix", + ]) { + setAgentConversationId(invalid); + expect(scope.getScopeData().conversationId).toBeUndefined(); + } + }); + }); +}); diff --git a/tests/clinical-math-bounds.test.ts b/tests/clinical-math-bounds.test.ts index 1fc5074ce7..df2da6538d 100644 --- a/tests/clinical-math-bounds.test.ts +++ b/tests/clinical-math-bounds.test.ts @@ -4,7 +4,43 @@ import "./setup/jsdom.setup"; import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createElement as h } from "react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const syntheticRendererReview = vi.hoisted(() => ({ enabled: false })); + +// Exercise retained draft input ergonomics without changing real release metadata. +// This module-local test fixture is not a clinician's review or publication approval. +vi.mock("@/lib/calculators/clinical-math/governance.json", async (importOriginal) => { + const actual = await importOriginal<{ + default: { + releaseStatus: string; + clinicalReview: { + status: string; + reviewer: string | null; + reviewedOn: string | null; + approvalEvidence: string | null; + }; + }; + }>(); + return { + default: { + ...actual.default, + get releaseStatus() { + return syntheticRendererReview.enabled ? "available" : actual.default.releaseStatus; + }, + get clinicalReview() { + return syntheticRendererReview.enabled + ? { + status: "approved", + reviewer: "Synthetic renderer test fixture; no clinical approval", + reviewedOn: "2000-01-01", + approvalEvidence: "test-only synthetic renderer state", + } + : actual.default.clinicalReview; + }, + }, + }; +}); import { ClinicalMathPanel } from "@/components/calculators/clinical-math-panel"; import { PatientProfileProvider } from "@/components/clinical-dashboard/patient-profile-context"; @@ -14,6 +50,10 @@ import { calculateProportionalLithiumDose, evaluateLithiumLevel } from "@/lib/ca import { calculateQtc } from "@/lib/calculators/clinical-math/qtc"; import { calculateCkdEpi2021, calculateCockcroftGault } from "@/lib/calculators/clinical-math/renal"; +afterEach(() => { + syntheticRendererReview.enabled = false; +}); + describe("Clinical Math Physiological Bounds & Input Ergonomics", () => { describe("calculateQtc physiological bounds (P0-2)", () => { it("rejects QT interval below 150 ms (e.g. seconds entered instead of milliseconds) and above 800 ms", () => { @@ -390,7 +430,19 @@ describe("Clinical Math Physiological Bounds & Input Ergonomics", () => { expect(scrMgDlInput).toHaveAttribute("step", "0.01"); }); - it("renders ClinicalMathPanel bedside tabs for Renal, QTc, and Lithium calculators", async () => { + it("withholds bedside inputs and results under the actual pending clinical-review metadata", () => { + render(h(ClinicalMathPanel, { defaultOpen: true })); + expect(screen.getByTestId("clinical-math-release-hold")).toHaveTextContent("dedicated clinical validation"); + expect(screen.queryByRole("tablist")).not.toBeInTheDocument(); + expect(screen.queryByRole("spinbutton")).not.toBeInTheDocument(); + expect(screen.queryByTestId("clinical-math-renal-results")).not.toBeInTheDocument(); + expect(screen.queryByTestId("clinical-math-qtc-results")).not.toBeInTheDocument(); + expect(screen.queryByTestId("clinical-math-lithium-results")).not.toBeInTheDocument(); + expect(screen.queryByTestId("clinical-math-lithium-suggested-dose")).not.toBeInTheDocument(); + }); + + it("preserves dormant Renal, QTc and Lithium input ergonomics with an isolated synthetic review fixture", async () => { + syntheticRendererReview.enabled = true; const user = userEvent.setup(); render(h(ClinicalMathPanel, { defaultOpen: true })); diff --git a/tests/clinical-math-governance.dom.test.tsx b/tests/clinical-math-governance.dom.test.tsx new file mode 100644 index 0000000000..e5514b8d78 --- /dev/null +++ b/tests/clinical-math-governance.dom.test.tsx @@ -0,0 +1,30 @@ +/** @vitest-environment jsdom */ + +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { ClinicalMathPanel } from "@/components/calculators/clinical-math-panel"; +import governance from "@/lib/calculators/clinical-math/governance.json"; + +describe("physiological calculator clinical validation hold", () => { + it("refuses available metadata while the dedicated clinical review is still pending", () => { + const previous = governance.releaseStatus; + try { + governance.releaseStatus = "available"; + render(); + expect(screen.getByTestId("clinical-math-release-hold")).toBeInTheDocument(); + expect(screen.queryByRole("spinbutton")).not.toBeInTheDocument(); + } finally { + governance.releaseStatus = previous; + } + }); + + it.each(["renal", "qtc", "lithium"] as const)("withholds %s tools even when requested open", (defaultTab) => { + render(); + expect(screen.getByTestId("clinical-math-release-hold")).toHaveTextContent("dedicated clinical validation"); + expect(screen.queryByRole("spinbutton")).not.toBeInTheDocument(); + expect(screen.queryByTestId("clinical-math-lithium-suggested-dose")).not.toBeInTheDocument(); + expect(screen.queryByRole("tablist")).not.toBeInTheDocument(); + expect(screen.queryByText(/Suggested dose:|TOXICITY ALERT|Level status:|Risk:/)).not.toBeInTheDocument(); + }); +}); diff --git a/tests/clinical-math-governance.test.ts b/tests/clinical-math-governance.test.ts new file mode 100644 index 0000000000..1a7afde784 --- /dev/null +++ b/tests/clinical-math-governance.test.ts @@ -0,0 +1,55 @@ +import { createHash } from "node:crypto"; +import { readFileSync, readdirSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +import governance from "@/lib/calculators/clinical-math/governance.json"; + +describe("physiological calculator governance inventory", () => { + it("keeps every formula and management implementation held without inventing review", () => { + expect(governance.releaseStatus).toBe("held"); + expect(governance.clinicalReview).toEqual({ + status: "pending", + reviewer: null, + reviewedOn: null, + approvalEvidence: null, + }); + expect(governance.algorithms.map((entry) => entry.id)).toEqual([ + "cockcroft-gault", + "ckd-epi-2021", + "qtc", + "lithium-level", + "proportional-lithium-dose", + ]); + for (const algorithm of governance.algorithms) { + expect(algorithm.formula.length).toBeGreaterThan(30); + expect(algorithm.sourceLeads.length).toBeGreaterThan(0); + expect(algorithm.reviewRequired.length).toBeGreaterThan(0); + } + }); + + it("detects drift in all physiological math files and the display boundary", () => { + const directory = "src/lib/calculators/clinical-math/"; + const implementations = readdirSync(directory) + .filter((file) => file.endsWith(".ts")) + .map((file) => directory + file); + expect(Object.keys(governance.implementationPins).sort()).toEqual( + [...implementations, "src/components/calculators/clinical-math-panel.tsx"].sort(), + ); + for (const [path, pin] of Object.entries(governance.implementationPins)) { + const actual = createHash("sha256").update(readFileSync(path)).digest("hex"); + expect( + actual, + `${path} changed: update the held inventory and review scope; a hash is not clinical approval`, + ).toBe(pin); + } + }); + + it("inventories every exported physiological calculation", () => { + const exported = ["renal", "qtc", "lithium"].flatMap((name) => { + const code = readFileSync(`src/lib/calculators/clinical-math/${name}.ts`, "utf8"); + return [...code.matchAll(/export function (\w+)\(/g)].map((match) => match[1]); + }); + expect(governance.algorithms.flatMap((entry) => entry.exports).sort()).toEqual(exported.sort()); + }); +}); diff --git a/tests/clinical-math-panel-breakpoint.dom.test.tsx b/tests/clinical-math-panel-breakpoint.dom.test.tsx index 1c7790cfa0..a2864cb332 100644 --- a/tests/clinical-math-panel-breakpoint.dom.test.tsx +++ b/tests/clinical-math-panel-breakpoint.dom.test.tsx @@ -1,6 +1,6 @@ /** @vitest-environment jsdom */ -import { act, fireEvent, render, screen } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("next/navigation", () => ({ @@ -52,29 +52,32 @@ describe("bedside calculator panel on the calculators page", () => { it("is not rendered in the sidebar or the mobile column at either breakpoint", () => { const setDesktop = installDesktopQuery(false); render(); - expect(screen.queryByTestId("clinical-math-panel-toggle")).toBeNull(); + const expectPanelAbsent = () => { + expect(screen.queryByTestId("clinical-math-panel")).not.toBeInTheDocument(); + expect(screen.queryByTestId("clinical-math-panel-toggle")).not.toBeInTheDocument(); + expect(screen.queryByTestId("clinical-math-release-hold")).not.toBeInTheDocument(); + expect(screen.queryByTestId("clinical-math-lithium-suggested-dose")).not.toBeInTheDocument(); + }; + expectPanelAbsent(); setDesktop(true); - expect(screen.queryByTestId("clinical-math-panel-toggle")).toBeNull(); + expectPanelAbsent(); + + setDesktop(false); + expectPanelAbsent(); }); }); describe("bedside calculator panel component (unwired, kept for clinician verification)", () => { - it("shows the lithium toxicity alert from 1.2 mmol/L before an age is entered", () => { - render(); - - fireEvent.click(screen.getByTestId("clinical-math-panel-toggle")); - fireEvent.click(screen.getByTestId("clinical-math-tab-lithium")); - const level = screen.getByTestId("clinical-math-lithium-level"); - - fireEvent.change(level, { target: { value: "0.9" } }); - expect(screen.getByTestId("clinical-math-lithium-results")).toHaveTextContent("Enter current 12-hour trough level"); + it("withholds lithium inputs and management advice while dedicated clinical validation is pending", () => { + render(); - fireEvent.change(level, { target: { value: "1.2" } }); - const results = screen.getByTestId("clinical-math-lithium-results"); - expect(results).toHaveTextContent("Level status: toxic"); - expect(results).toHaveTextContent("TOXICITY ALERT"); - expect(results).toHaveTextContent("Enter patient age to see the target range."); - expect(results).not.toHaveTextContent("target 0"); + expect(screen.getByTestId("clinical-math-release-hold")).toHaveTextContent("dedicated clinical validation"); + expect(screen.getByTestId("clinical-math-release-hold")).toHaveTextContent("approved local reference"); + expect(screen.queryByRole("spinbutton")).not.toBeInTheDocument(); + expect(screen.queryByRole("tablist")).not.toBeInTheDocument(); + expect(screen.queryByTestId("clinical-math-lithium-level")).not.toBeInTheDocument(); + expect(screen.queryByTestId("clinical-math-lithium-results")).not.toBeInTheDocument(); + expect(screen.queryByText(/TOXICITY ALERT|Suggested dose:/)).not.toBeInTheDocument(); }); }); diff --git a/tests/cme-evidence-api.test.ts b/tests/cme-evidence-api.test.ts index 2f71f53f62..9cce3b069d 100644 --- a/tests/cme-evidence-api.test.ts +++ b/tests/cme-evidence-api.test.ts @@ -339,6 +339,24 @@ describe("removing a CME evidence file", () => { removal_reason: "Contains a patient name", }; + it("fails closed before storage deletion when the required durable-removal RPC is absent", async () => { + results.push({ data: entry, error: null }, { data: evidence, error: null }); + mocks.rpc.mockResolvedValueOnce({ data: null, error: { code: "PGRST202", message: "Missing durable RPC" } }); + expect((await remove(removeRequest({ reason: "Wrong file uploaded" }), downloadContext)).status).toBe(503); + expect(mocks.rpc).toHaveBeenCalledWith("cme_remove_evidence_v2", expect.any(Object)); + expect(mocks.remove).not.toHaveBeenCalled(); + }); + + it("retries a committed removal after its response was lost", async () => { + results.push({ data: entry, error: null }, { data: evidence, error: null }); + mocks.rpc.mockResolvedValueOnce({ data: null, error: { message: "Synthetic lost response after commit" } }); + expect((await remove(removeRequest({ reason: "Wrong file uploaded" }), downloadContext)).status).toBe(503); + results.push({ data: entry, error: null }, { data: removedRow, error: null }); + mocks.rpc.mockResolvedValueOnce({ data: removedRow, error: null }); + expect((await remove(removeRequest({ reason: "Wrong file uploaded" }), downloadContext)).status).toBe(200); + expect(mocks.rpc).toHaveBeenCalledTimes(2); + }); + it("records the reason, deletes the stored file, and returns the tombstone", async () => { results.push({ data: entry, error: null }, { data: { ...evidence, removed_at: null }, error: null }); mocks.rpc.mockResolvedValue({ data: removedRow, error: null }); @@ -346,7 +364,7 @@ describe("removing a CME evidence file", () => { expect(response.status).toBe(200); const body = await response.json(); expect(body.evidence).toMatchObject({ removalReason: "Contains a patient name", fileName: "Removed file" }); - expect(mocks.rpc).toHaveBeenCalledWith("cme_remove_evidence", { + expect(mocks.rpc).toHaveBeenCalledWith("cme_remove_evidence_v2", { p_owner_id: ownerId, p_evidence_id: evidenceId, p_reason: "Contains a patient name", @@ -372,15 +390,15 @@ describe("removing a CME evidence file", () => { expect((await remove(removeRequest({ reason: "Wrong file uploaded" }), downloadContext)).status).toBe(200); }); - it("queues cleanup when storage does not answer, and still reports success", async () => { - results.push({ data: entry, error: null }, { data: evidence, error: null }, { data: null, error: null }); + it("relies on the atomic cleanup obligation when storage does not answer", async () => { + results.push({ data: entry, error: null }, { data: evidence, error: null }); mocks.rpc.mockResolvedValue({ data: removedRow, error: null }); mocks.remove.mockResolvedValue({ data: null, error: { message: "storage down" } }); expect((await remove(removeRequest({ reason: "Wrong file uploaded" }), downloadContext)).status).toBe(200); - expect(queries.at(-1)?.table).toBe("storage_cleanup_jobs"); + expect(queries.some((query) => query.table === "storage_cleanup_jobs")).toBe(false); }); - it("says not found for a file already removed or owned by someone else", async () => { + it("says not found for a file owned by someone else", async () => { results.push({ data: entry, error: null }, { data: null, error: null }); expect((await remove(removeRequest({ reason: "Wrong file uploaded" }), downloadContext)).status).toBe(404); expect(mocks.rpc).not.toHaveBeenCalled(); diff --git a/tests/diagnostic-minimisation.test.ts b/tests/diagnostic-minimisation.test.ts new file mode 100644 index 0000000000..f8d952a642 --- /dev/null +++ b/tests/diagnostic-minimisation.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as privacy from "@/lib/privacy"; +import { redactLogContext } from "@/lib/logger"; + +const privateText = "Synthetic Person 1990-01-02 private-guideline.pdf"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.resetModules(); + vi.doUnmock("@/lib/supabase/admin"); + vi.doUnmock("../worker/observability"); +}); + +describe("content-free operational diagnostics", () => { + it("keeps only fixed classifications and operational codes, never error prose", () => { + const error = Object.assign(new Error(privateText), { + code: "23505", + status: 503, + details: privateText, + hint: privateText, + }); + const details = privacy.contentFreeErrorDetails(error); + expect(details).toEqual({ errorType: "Error", errorCode: "23505", status: 503 }); + expect(JSON.stringify(details)).not.toContain(privateText); + }); + + it("does not trust arbitrary names, codes, objects or thrown strings", () => { + for (const error of [ + Object.assign(new Error(privateText), { name: privateText, code: privateText, status: 1000 }), + privateText, + { message: privateText, code: { privateText }, status: privateText }, + ]) { + const text = JSON.stringify(privacy.contentFreeErrorDetails(error)); + expect(text).not.toContain(privateText); + expect(text).not.toContain("1990-01-02"); + } + }); + + it("redacts user-controlled filenames and storage paths at the logger boundary", () => { + expect( + redactLogContext({ fileName: privateText, storagePath: "owner/documents/" + privateText, fileCount: 1 }), + ).toEqual({ fileName: "[redacted]", storagePath: "[redacted]", fileCount: 1 }); + }); + + it("never puts a fatal exception's content in console or webhook output", async () => { + const error = Object.assign(new Error(privateText), { code: "23505" }); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: () => { + throw error; + }, + })); + vi.doMock("../worker/observability", () => ({ + captureWorkerException: vi.fn(), + flushWorkerErrorTracking: vi.fn(async () => undefined), + })); + vi.stubEnv("WORKER_FAILURE_WEBHOOK_URL", "https://hooks.invalid/synthetic-audit"); + const post = vi.fn(async () => new Response(null, { status: 200 })); + vi.stubGlobal("fetch", post); + const printed = vi.spyOn(console, "error").mockImplementation(() => undefined); + const exit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + const { runWorkerMain } = await import("../worker/main"); + await runWorkerMain(); + expect(exit).toHaveBeenCalledWith(1); + expect(post).toHaveBeenCalledTimes(1); + const sent = String(post.mock.calls[0]?.[1]?.body); + expect(sent).toContain("CRITICAL"); + expect(sent).not.toContain(privateText); + expect(JSON.stringify(printed.mock.calls)).not.toContain(privateText); + }); + + it("reports the allowlisted code of a wrapped abort cause without its content", async () => { + const cause = Object.assign(new Error(privateText), { code: "23505" }); + const { WorkerAbortError } = await import("../worker/runtime-control"); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: () => { + throw new WorkerAbortError("Ingestion job claim failed in --once mode", 1, { cause }); + }, + })); + vi.doMock("../worker/observability", () => ({ + captureWorkerException: vi.fn(), + flushWorkerErrorTracking: vi.fn(async () => undefined), + })); + vi.stubEnv("WORKER_FAILURE_WEBHOOK_URL", "https://hooks.invalid/synthetic-audit"); + const post = vi.fn(async () => new Response(null, { status: 200 })); + vi.stubGlobal("fetch", post); + vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + const { runWorkerMain } = await import("../worker/main"); + await runWorkerMain(); + const sent = String(post.mock.calls[0]?.[1]?.body); + expect(sent).toContain("errorCode=23505"); + expect(sent).not.toContain(privateText); + }); +}); diff --git a/tests/drift-rootless.test.ts b/tests/drift-rootless.test.ts new file mode 100644 index 0000000000..1aff310817 --- /dev/null +++ b/tests/drift-rootless.test.ts @@ -0,0 +1,399 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { gzipSync } from "node:zlib"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { manifestText, parseSnapshot, replaySchema, writeManifestAtomically } from "../scripts/generate-drift-manifest"; +import { diffManifests, IGNORED_MANIFEST_KEYS } from "../scripts/check-drift-manifest-freshness"; +import { + controlPath, + imageEnvironment, + removeOwnedRootlessRun, + rootlessOptions, + ROOTLESS_SUPERVISOR, + verifyOfflineImage, +} from "../scripts/lib/drift-rootless"; + +const directories: string[] = []; +async function temporary() { + const directory = await mkdtemp(path.join(os.tmpdir(), "drift-rootless-test-")); + directories.push(directory); + return realpath(directory); +} +afterEach(async () => { + for (const directory of directories.splice(0)) await rm(directory, { recursive: true, force: true }); +}); +const digest = (bytes: Buffer) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`; + +async function imageFixture( + options: { corruptLayer?: boolean; wrongDiffId?: boolean; architecture?: string; duplicatePlatform?: boolean } = {}, +) { + const directory = await temporary(); + await mkdir(path.join(directory, "blobs/sha256"), { recursive: true }); + const add = async (bytes: Buffer, mediaType: string) => { + const descriptor = { digest: digest(bytes), size: bytes.length, mediaType }; + await writeFile(path.join(directory, "blobs/sha256", descriptor.digest.slice(7)), bytes); + return descriptor; + }; + const raw = Buffer.from("synthetic local layer bytes; no executable image is supplied"); + const layer = await add(gzipSync(raw), "application/vnd.oci.image.layer.v1.tar+gzip"); + const config = { + os: "linux", + architecture: options.architecture ?? "amd64", + rootfs: { diff_ids: [options.wrongDiffId ? `sha256:${"0".repeat(64)}` : digest(raw)] }, + config: { + Env: [ + "PATH=/bin", + "PGDATA=/var/lib/postgresql/data", + "POSTGRES_HOST=/var/run/postgresql", + "POSTGRES_DB=postgres", + ], + Entrypoint: ["docker-entrypoint.sh"], + Cmd: ["postgres", "-D", "/etc/postgresql"], + WorkingDir: "/", + }, + }; + const configDescriptor = await add(Buffer.from(JSON.stringify(config)), "application/vnd.oci.image.config.v1+json"); + const manifest = await add( + Buffer.from( + JSON.stringify({ + mediaType: "application/vnd.oci.image.manifest.v1+json", + config: configDescriptor, + layers: [layer], + }), + ), + "application/vnd.oci.image.manifest.v1+json", + ); + const child = { ...manifest, platform: { os: "linux", architecture: "amd64" } }; + const index = await add( + Buffer.from( + JSON.stringify({ + mediaType: "application/vnd.oci.image.index.v1+json", + manifests: options.duplicatePlatform ? [child, child] : [child], + }), + ), + "application/vnd.oci.image.index.v1+json", + ); + await writeFile(path.join(directory, "oci-layout"), JSON.stringify({ imageLayoutVersion: "1.0.0" })); + await writeFile( + path.join(directory, "index.json"), + JSON.stringify({ manifests: [{ ...manifest, annotations: { "org.opencontainers.image.ref.name": "verified" } }] }), + ); + if (options.corruptLayer) + await writeFile(path.join(directory, "blobs/sha256", layer.digest.slice(7)), "corrupt bytes"); + return { + directory, + image: `supabase/postgres:test@${index.digest}`, + config, + index, + manifest, + layer, + configDescriptor, + }; +} + +const inputs = { + rolesSql: "-- exact role bootstrap", + scaffoldSql: "-- exact storage scaffold", + schemaSql: "-- complete schema\nselect 'do not truncate';", +}; +function completeSnapshot() { + return { + snapshot_version: 2, + captured_at: "volatile", + migration_history_probe: "no_history_table", + migration_history: [], + extensions: [{}], + tables: Array.from({ length: 31 }, (_, id) => ({ name: `t${id}` })), + views: [{}], + functions: Array.from({ length: 41 }, (_, id) => ({ signature: `f${id}()` })), + indexes: Array.from({ length: 101 }, (_, id) => ({ name: `i${id}` })), + policies: [{}], + constraints: [{}], + triggers: [{}], + storage_buckets: [{}], + }; +} + +describe("explicit offline rootless inputs", () => { + const args = [ + "--oci-layout", + "/private/image", + "--scratch-root", + "/private/scratch", + "--umoci", + "/private/umoci", + "--umoci-sha256", + "a".repeat(64), + "--evidence", + "/private/evidence.json", + ]; + it.each(["--keep", "--port", "--container"])( + "rejects unsupported Docker option %s before inspecting inputs", + (flag) => { + expect(() => rootlessOptions([...args, flag, "5432"], "image", "/repo")).toThrow("not supported"); + }, + ); + it("requires every offline path and an exact executable hash", () => { + expect(() => rootlessOptions([], "image", "/repo")).toThrow("--oci-layout"); + expect(() => + rootlessOptions( + args.map((arg) => (arg === "a".repeat(64) ? "bad" : arg)), + "image", + "/repo", + ), + ).toThrow("SHA-256"); + expect(rootlessOptions(args, "image", "/repo").umociSha256).toBe("a".repeat(64)); + }); + it("verifies actual index, child/config, compressed bytes and ordered uncompressed diff IDs", async () => { + const fixture = await imageFixture(); + const verified = await verifyOfflineImage(fixture.directory, fixture.image); + expect(verified.manifest_digest).toBe(fixture.manifest.digest); + expect(verified.config_digest).toBe(fixture.configDescriptor.digest); + expect(verified.layers[0].digest).toBe(fixture.layer.digest); + expect(verified.layers[0].diff_id).toBe(fixture.config.rootfs.diff_ids[0]); + expect(verified.tag).toBe("verified"); + }); + it.each([{ corruptLayer: true }, { wrongDiffId: true }, { architecture: "arm64" }, { duplicatePlatform: true }])( + "refuses invalid local provenance %j", + async (options) => { + const fixture = await imageFixture(options); + await expect(verifyOfflineImage(fixture.directory, fixture.image)).rejects.toThrow(); + }, + ); + it("rejects the verified tag when another local descriptor uses it for different image bytes", async () => { + const fixture = await imageFixture(); + const annotations = { "org.opencontainers.image.ref.name": "verified" }; + await writeFile( + path.join(fixture.directory, "index.json"), + JSON.stringify({ + manifests: [ + { ...fixture.manifest, annotations }, + { digest: `sha256:${"0".repeat(64)}`, size: 1, mediaType: fixture.manifest.mediaType, annotations }, + ], + }), + ); + await expect(verifyOfflineImage(fixture.directory, fixture.image)).rejects.toThrow("unambiguous tag"); + }); + + it("does not trust the pinned filename when its index bytes were changed", async () => { + const fixture = await imageFixture(); + await writeFile(path.join(fixture.directory, "blobs/sha256", fixture.index.digest.slice(7)), "{}"); + await expect(verifyOfflineImage(fixture.directory, fixture.image)).rejects.toThrow("index digest mismatch"); + }); + it("refuses a missing blob without attempting acquisition", async () => { + const fixture = await imageFixture(); + await rm(path.join(fixture.directory, "blobs/sha256", fixture.configDescriptor.digest.slice(7))); + await expect(verifyOfflineImage(fixture.directory, fixture.image)).rejects.toThrow(); + }); + it.skipIf(process.platform === "win32")( + "refuses symlink control paths without rejecting ordinary image content", + async () => { + const fixture = await imageFixture(); + const alias = path.join(await temporary(), "alias"); + await symlink(fixture.directory, alias); + await expect(controlPath(alias, true)).rejects.toThrow("symlinks"); + await expect(controlPath(fixture.directory, true)).resolves.toBe(fixture.directory); + }, + ); + it("uses verified image settings and rejects changed startup or socket contracts", async () => { + const fixture = await imageFixture(); + expect(imageEnvironment(fixture.config).POSTGRES_HOST).toBe("/var/run/postgresql"); + const changed = structuredClone(fixture.config); + changed.config.Env = changed.config.Env.map((entry) => + entry.startsWith("POSTGRES_HOST=") ? "POSTGRES_HOST=live.example" : entry, + ); + expect(() => imageEnvironment(changed)).toThrow("bootstrap differs"); + changed.config.Env = [...fixture.config.config.Env, "LD_PRELOAD=/host/injected.so"]; + expect(() => imageEnvironment(changed)).toThrow("environment"); + }); +}); + +describe("shared actual schema replay contract", () => { + it("applies exact inputs in order, dynamically selects Storage owner, and captures only after full replay", async () => { + const calls: Array<{ role: string; sql: string; tuples: boolean }> = []; + const snapshot = await replaySchema(async (role, sql, tuples = false) => { + calls.push({ role, sql, tuples }); + if (calls.length === 2) return " synthetic_storage_owner\n"; + if (calls.length === 5) return JSON.stringify(completeSnapshot()); + return ""; + }, inputs); + expect(calls.map(({ role }) => role)).toEqual([ + "postgres", + "postgres", + "synthetic_storage_owner", + "postgres", + "postgres", + ]); + expect(calls[0].sql).toBe(inputs.rolesSql); + expect(calls[2].sql).toBe(inputs.scaffoldSql); + expect(calls[3].sql).toBe(inputs.schemaSql); + expect(calls[4]).toEqual({ role: "postgres", sql: "select public.schema_drift_snapshot()::text;", tuples: true }); + expect(snapshot).not.toHaveProperty("captured_at"); + expect(snapshot.functions).toHaveLength(41); + }); + it("refuses an unavailable storage owner before scaffold or schema writes", async () => { + const sql = vi.fn().mockResolvedValue(""); + await expect(replaySchema(sql, inputs)).rejects.toThrow("owner"); + expect(sql).toHaveBeenCalledTimes(2); + }); + it.each([1, 3, 4, 5])("aborts on SQL stage %i without overwriting the previous manifest", async (failedStage) => { + const output = path.join(await temporary(), "manifest.json"); + await writeFile(output, "previous manifest bytes"); + let stage = 0; + await expect( + (async () => { + const snapshot = await replaySchema(async () => { + stage += 1; + if (stage === failedStage) throw new Error("synthetic SQL failure"); + return stage === 2 ? "synthetic_storage_owner" : ""; + }, inputs); + writeManifestAtomically(output, manifestText("image", "hash", 1, snapshot)); + })(), + ).rejects.toThrow("synthetic SQL failure"); + expect(stage).toBe(failedStage); + expect(await readFile(output, "utf8")).toBe("previous manifest bytes"); + }); + it("rejects tiny, malformed and fabricated successful-history snapshots", () => { + expect(() => parseSnapshot("{}")).toThrow("inventory"); + expect(() => parseSnapshot("not JSON")).toThrow(); + expect(() => parseSnapshot(JSON.stringify({ ...completeSnapshot(), migration_history_probe: "ok" }))).toThrow( + "contract", + ); + }); + it("keeps one manifest format across backends and does not hide new provenance keys from CI", async () => { + const snapshot = completeSnapshot(); + const docker = JSON.parse(manifestText("original@index", "schema", 1, snapshot)); + const rootless = JSON.parse(manifestText("original@index", "schema", 100, snapshot)); + expect(diffManifests(docker, rootless)).toEqual([]); + expect(rootless.generator).toBe("scripts/generate-drift-manifest.ts"); + expect(Object.keys(rootless).sort()).toEqual([ + "generated_at", + "generator", + "postgres_image", + "replay_seconds", + "schema_sha256", + "snapshot", + ]); + expect([...IGNORED_MANIFEST_KEYS].sort()).toEqual(["generated_at", "replay_seconds"]); + expect(diffManifests(docker, { ...rootless, backend: "rootless" })).toHaveLength(1); + const output = path.join(await temporary(), "manifest.json"); + writeManifestAtomically(output, JSON.stringify(rootless)); + expect(JSON.parse(await readFile(output, "utf8"))).toEqual(rootless); + expect(await readdir(path.dirname(output))).toEqual(["manifest.json"]); + }); +}); + +describe("single-namespace guest supervisor", () => { + it.skipIf(process.platform !== "linux").each([false, true])( + "records actual shutdown outcome (pg_ctl fails: %s)", + async (failedCleanup) => { + const directory = await temporary(); + const bin = path.join(directory, "bin"); + const output = path.join(directory, "output"); + await mkdir(bin); + await mkdir(output); + const executable = (name: string, body: string) => + writeFile(path.join(bin, name), `#!/bin/bash\n${body}\n`, { mode: 0o700 }); + await executable( + "docker-entrypoint.sh", + `trap 'exit 0' TERM +echo $$ > "$PGDATA/server.pid" +echo 'PostgreSQL init process complete; ready for start up.' +while true; do /bin/sleep 0.01; done`, + ); + await executable( + "pg_isready", + `count=0 +if [ -f "$PGDATA/checks" ]; then count=$(cat "$PGDATA/checks"); fi +count=$((count+1)) +printf '%s' "$count" > "$PGDATA/checks" +[ "$count" -ne 2 ]`, + ); + await executable("sleep", "exec /bin/sleep 0.001"); + await executable("psql", 'cat > "$PGDATA/received.sql"; printf "actual fixture response\\n"'); + await executable("pg_ctl", failedCleanup ? "exit 7" : 'kill -TERM "$(cat "$PGDATA/server.pid")"; exit 0'); + // Substitute only the guest mount location; run the maintained shell itself + // against harmless executable fixtures, with no bwrap or database started. + const supervisor = ROOTLESS_SUPERVISOR.replaceAll("/tmp/drift-output", output); + const sql = "select 'fixture';"; + const result = spawnSync("/bin/bash", ["-c", supervisor], { + env: { PATH: `${bin}:/usr/bin:/bin`, NODE_ENV: "test", PGDATA: directory, POSTGRES_HOST: directory }, + input: `synthetic-password\n1 tuples ${Buffer.from("postgres").toString("base64")} ${Buffer.from(sql).toString("base64")}\n`, + encoding: "utf8", + timeout: 5_000, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(failedCleanup ? 1 : 0); + expect(Number(await readFile(path.join(directory, "checks"), "utf8"))).toBeGreaterThanOrEqual(7); + expect(await readFile(path.join(directory, "received.sql"), "utf8")).toBe(sql); + expect((await readFile(path.join(output, "1.status"), "utf8")).trim()).toBe("0"); + expect((await readFile(path.join(output, "cleanup-status"), "utf8")).trim().split(" ")[0]).toBe( + failedCleanup ? "7" : "0", + ); + }, + ); +}); + +describe("owned immutable rootfs directory cleanup", () => { + it.skipIf(process.platform !== "linux")( + "removes 0555 owned directories without chmodding or following an outside symlink", + async () => { + const parent = await temporary(); + const run = await mkdtemp(path.join(parent, "drift-rootless-")); + const readonly = path.join(run, "bundle/rootfs/nix/store/immutable/bin"); + await mkdir(readonly, { recursive: true }); + await writeFile(path.join(readonly, "tool"), "owned disposable bytes", { mode: 0o444 }); + const outside = path.join(await temporary(), "outside"); + await mkdir(outside); + const outsideFile = path.join(outside, "retained"); + await writeFile(outsideFile, "must remain untouched", { mode: 0o444 }); + // The native test runner uses a restrictive umask; establish exact fixture modes. + await chmod(outsideFile, 0o444); + await chmod(outside, 0o555); + await symlink(outside, path.join(readonly, "outside-link")); + await chmod(readonly, 0o555); + await chmod(path.dirname(readonly), 0o555); + const marker = JSON.stringify({ run, nonce: "synthetic-owned-run" }); + await writeFile(path.join(run, "owner.json"), marker); + const identity = await lstat(run); + try { + await removeOwnedRootlessRun(run, { ino: identity.ino, dev: identity.dev, uid: identity.uid, marker }); + await expect(lstat(run)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(outsideFile, "utf8")).toBe("must remain untouched"); + expect((await lstat(outside)).mode & 0o777).toBe(0o555); + expect((await lstat(outsideFile)).mode & 0o777).toBe(0o444); + } finally { + await chmod(outside, 0o700); + // Let fixture disposal work even when the implementation under test fails. + await chmod(path.dirname(readonly), 0o700).catch(() => undefined); + await chmod(readonly, 0o700).catch(() => undefined); + } + }, + ); + + it.skipIf(process.platform !== "linux")( + "refuses a mismatched marker before changing any directory modes", + async () => { + const run = await mkdtemp(path.join(await temporary(), "drift-rootless-")); + const readonly = path.join(run, "untouched"); + await mkdir(readonly, { mode: 0o555 }); + await chmod(readonly, 0o555); + await writeFile(path.join(run, "owner.json"), "actual-marker"); + const identity = await lstat(run); + await expect( + removeOwnedRootlessRun(run, { + ino: identity.ino, + dev: identity.dev, + uid: identity.uid, + marker: "wrong-marker", + }), + ).rejects.toThrow("marker changed"); + expect((await lstat(readonly)).mode & 0o777).toBe(0o555); + expect(await readFile(path.join(run, "owner.json"), "utf8")).toBe("actual-marker"); + }, + ); +}); diff --git a/tests/factsheets-summary-context.test.ts b/tests/factsheets-summary-context.test.ts new file mode 100644 index 0000000000..e709846b2f --- /dev/null +++ b/tests/factsheets-summary-context.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { FACTSHEET_DEMO_NOTICE, factsheets } from "@/lib/factsheets-data"; +import { buildStaticSiteContentRecords } from "@/lib/site-content/adapters"; +import { buildStaticSiteContentManifest, siteContentValueHash } from "@/lib/site-content/site-content-manifest"; +import { SITE_CONTENT_REGISTRY_VERSION } from "@/lib/site-content/site-content-registry"; + +describe("Factsheet short-summary context", () => { + it("preserves demonstration-use context and content month without inventing clinician approval", () => { + const records = buildStaticSiteContentRecords().filter((record) => record.domain === "factsheets"); + expect(records).toHaveLength(factsheets.length); + for (const sheet of factsheets) { + const record = records.find((entry) => entry.logicalId === `factsheets:${sheet.slug}`)!; + expect(record.body).toContain(FACTSHEET_DEMO_NOTICE); + expect(record.body).toContain(`Content month: ${sheet.reviewedOn}`); + expect(record.body).not.toContain("Review date:"); + // Local correspondence review and currentness are distinct from clinician approval. + expect(record.validationStatus).toBe("locally_reviewed"); + expect(record.sourceStatus).toBe("current"); + expect(record.sourceLineage).toContainEqual( + expect.objectContaining({ + sourceId: "repository:src/lib/factsheets-data.ts:summary-context", + sourceHash: siteContentValueHash({ notice: FACTSHEET_DEMO_NOTICE, dateMeaning: "content-month" }), + }), + ); + } + const manifest = buildStaticSiteContentManifest(records, { + gitSha: "b0edabf0b73a806535aa3289e5de8023cf39997f", + registryVersion: SITE_CONTENT_REGISTRY_VERSION, + generatedAt: "2026-10-17T00:00:00.000Z", + }); + expect(manifest.records.every((entry) => entry.eligible)).toBe(true); + }); +}); diff --git a/tests/favourites-session-race.dom.test.tsx b/tests/favourites-session-race.dom.test.tsx new file mode 100644 index 0000000000..d0ba03168e --- /dev/null +++ b/tests/favourites-session-race.dom.test.tsx @@ -0,0 +1,320 @@ +/** @vitest-environment jsdom */ +// Mounted immutable identities; every fetch is intercepted. No backend authorization claim. +import { act, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AccountDataProvider, useAccountData } from "@/components/account-data-provider"; + +const state = vi.hoisted(() => ({ + auth: { + status: "authenticated" as string, + authEpoch: 1, + authorizationHeader: { Authorization: "Bearer synthetic-A" } as Record, + session: { user: { id: "synthetic-A" } }, + markSessionExpired: vi.fn(), + }, +})); +vi.mock("@/lib/supabase/client", () => ({ useAuthSession: () => state.auth })); +function Probe() { + const state = useAccountData(); + return ( +
+ {state.ready ? "ready" : "loading"} + {state.isSaved("therapy", "cbt") ? "saved" : "empty"} + +
+ ); +} +const snapshot = { version: 1, favourites: [], sets: [] }; +function tree() { + return ( + + + + ); +} +describe("audit: favourites do not cross account boundaries", () => { + beforeEach(() => { + state.auth = { + ...state.auth, + status: "authenticated", + authEpoch: state.auth.authEpoch + 1, + authorizationHeader: { Authorization: "Bearer synthetic-A" }, + session: { user: { id: "synthetic-A" } }, + }; + state.auth.markSessionExpired.mockClear(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + for (const transition of ["signed_out", "account-B"] as const) { + it(`ignores account A save completion after ${transition}`, async () => { + let resolvePut!: (response: Response) => void; + const put = new Promise((resolve) => { + resolvePut = resolve; + }); + const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "PUT") return put; + if (!init?.method || init.method === "GET") return Promise.resolve(Response.json(snapshot)); + throw new Error("Audit probe refused unexpected fetch method"); + }); + vi.stubGlobal("fetch", fetchMock); + const view = render(tree()); + await waitFor(() => expect(screen.getByTestId("ready").textContent).toBe("ready")); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(fetchMock.mock.calls.some(([, init]) => init?.method === "PUT")).toBe(true)); + state.auth = { + ...state.auth, + authEpoch: state.auth.authEpoch + 1, + status: transition === "signed_out" ? "signed_out" : "authenticated", + authorizationHeader: transition === "signed_out" ? {} : { Authorization: "Bearer synthetic-B" }, + session: { user: { id: transition === "signed_out" ? "" : "synthetic-B" } }, + }; + view.rerender(tree()); + await waitFor(() => expect(screen.getByTestId("saved").textContent).toBe("empty")); + await act(async () => { + resolvePut(Response.json({ version: 1, saved: true })); + }); + expect(screen.getByTestId("saved").textContent).toBe("empty"); + }); + } +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +function switchAccount() { + state.auth = { + ...state.auth, + authEpoch: state.auth.authEpoch + 1, + authorizationHeader: { Authorization: "Bearer synthetic-B" }, + session: { user: { id: "synthetic-B" } }, + }; +} +const itemA = { + contentType: "therapy", + contentKey: "cbt", + createdAt: "2026-08-23T00:00:00.000Z", + setId: null, + sortOrder: 10, + pinnedAt: null, + lastOpenedAt: null, +}; +const setA = { + id: "4f8a3d2e-c1b0-4a9e-8d7c-6b5a4f3e2d1c", + name: "Ward round", + sortOrder: 0, + createdAt: "2026-08-23T00:00:00.000Z", + updatedAt: "2026-08-23T00:00:00.000Z", +}; +const wrapper = ({ children }: { children: ReactNode }) => {children}; + +describe("favourites identity fencing for reads, failures and queues", () => { + beforeEach(() => { + state.auth = { + ...state.auth, + status: "authenticated", + authEpoch: state.auth.authEpoch + 1, + authorizationHeader: { Authorization: "Bearer synthetic-A" }, + session: { user: { id: "synthetic-A" } }, + }; + state.auth.markSessionExpired.mockClear(); + }); + afterEach(() => vi.unstubAllGlobals()); + + it("drops an old GET body even when fetch already resolved before the switch", async () => { + const body = deferred(); + let reads = 0; + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve(++reads === 1 ? { ok: true, status: 200, json: () => body.promise } : Response.json(snapshot)), + ), + ); + const view = renderHook(useAccountData, { wrapper }); + await waitFor(() => expect(reads).toBe(1)); + act(() => { + switchAccount(); + view.rerender(); + }); + await waitFor(() => expect(view.result.current.ready).toBe(true)); + await act(async () => body.resolve({ ...snapshot, favourites: [itemA], sets: [setA] })); + expect(view.result.current.favouriteItems).toEqual([]); + expect(view.result.current.favouriteSets).toEqual([]); + }); + + it("clears accepted A state on transition and ignores an old GET 401", async () => { + const pending = deferred(); + let reads = 0; + vi.stubGlobal( + "fetch", + vi.fn(() => { + reads += 1; + if (reads === 1) return Promise.resolve(Response.json({ ...snapshot, favourites: [itemA], sets: [setA] })); + if (reads === 2) return pending.promise; + return Promise.resolve(Response.json(snapshot)); + }), + ); + const view = renderHook(useAccountData, { wrapper }); + await waitFor(() => expect(view.result.current.isSaved("therapy", "cbt")).toBe(true)); + act(() => view.result.current.reload()); + await waitFor(() => expect(reads).toBe(2)); + act(() => { + switchAccount(); + view.rerender(); + }); + expect(view.result.current.favouriteItems).toEqual([]); + expect(view.result.current.favouriteSets).toEqual([]); + await waitFor(() => expect(view.result.current.ready).toBe(true)); + await act(async () => pending.resolve(Response.json({ error: "Synthetic A expired" }, { status: 401 }))); + expect(state.auth.markSessionExpired).not.toHaveBeenCalled(); + expect(view.result.current.loadError).toBeNull(); + }); + + it("does not clear B favourites when A clear-all succeeds late", async () => { + const pending = deferred(); + vi.stubGlobal( + "fetch", + vi.fn((_url: unknown, init?: RequestInit) => + init?.method === "DELETE" + ? pending.promise + : Promise.resolve(Response.json({ ...snapshot, favourites: [itemA] })), + ), + ); + const view = renderHook(useAccountData, { wrapper }); + await waitFor(() => expect(view.result.current.ready).toBe(true)); + let operation!: Promise; + act(() => { + operation = view.result.current.clearFavourites(); + }); + await act(async () => {}); + act(() => { + switchAccount(); + view.rerender(); + }); + await waitFor(() => expect(view.result.current.ready).toBe(true)); + await act(async () => { + pending.resolve(Response.json({ version: 1, cleared: true })); + await operation; + }); + expect(view.result.current.isSaved("therapy", "cbt")).toBe(true); + }); + + for (const kind of ["membership", "structured", "clear"] as const) { + it(`ignores stale ${kind} 401 and rollback after B has loaded`, async () => { + const pending = deferred(); + vi.stubGlobal( + "fetch", + vi.fn((_url: unknown, init?: RequestInit) => + init?.method + ? pending.promise + : Promise.resolve(Response.json({ ...snapshot, favourites: [itemA], sets: [setA] })), + ), + ); + const view = renderHook(useAccountData, { wrapper }); + await waitFor(() => expect(view.result.current.ready).toBe(true)); + let operation!: Promise; + act(() => { + operation = + kind === "membership" + ? view.result.current.setFavourite("therapy", "cbt", false) + : kind === "structured" + ? view.result.current.deleteFavouriteSet(setA.id) + : view.result.current.clearFavourites(); + }); + await act(async () => {}); + act(() => { + switchAccount(); + view.rerender(); + }); + await waitFor(() => expect(view.result.current.ready).toBe(true)); + const before = view.result.current.favouriteItems; + await act(async () => { + pending.resolve(Response.json({ error: "Synthetic expired A" }, { status: 401 })); + await operation; + }); + expect(state.auth.markSessionExpired).not.toHaveBeenCalled(); + expect(view.result.current.error).toBeNull(); + expect(view.result.current.favouriteItems).toEqual(before); + expect(view.result.current.favouriteSets).toEqual([setA]); + }); + } + + it("does not dispatch old queued membership, structured or clear writes, and B does not wait on A", async () => { + const pending = deferred(); + const writes: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn((_url: unknown, init?: RequestInit) => { + if (!init?.method) return Promise.resolve(Response.json(snapshot)); + writes.push(`${state.auth.session.user.id}:${init.method}`); + return writes.length === 1 ? pending.promise : Promise.resolve(Response.json({ version: 1, saved: true })); + }), + ); + const view = renderHook(useAccountData, { wrapper }); + await waitFor(() => expect(view.result.current.ready).toBe(true)); + let operations!: Promise[]; + act(() => { + const old = view.result.current; + operations = [ + old.setFavourite("therapy", "cbt", true), + old.setFavourite("therapy", "cbt", false), + old.clearFavourites(), + old.createFavouriteSet("Ward round"), + ]; + }); + await waitFor(() => expect(writes).toHaveLength(1)); + act(() => { + switchAccount(); + view.rerender(); + }); + await waitFor(() => expect(view.result.current.ready).toBe(true)); + await act(async () => { + await view.result.current.setFavourite("therapy", "cbt", true); + }); + expect(writes).toEqual(["synthetic-A:PUT", "synthetic-B:PUT"]); + await act(async () => { + pending.resolve(Response.json({ version: 1, saved: true })); + await Promise.all(operations); + }); + expect(writes).toEqual(["synthetic-A:PUT", "synthetic-B:PUT"]); + expect(view.result.current.isSaved("therapy", "cbt")).toBe(true); + expect(view.result.current.favouriteSets).toEqual([]); + }); + + for (const status of [200, 500]) { + it(`does not apply a stale create-set response or rollback (${status})`, async () => { + const pending = deferred(); + vi.stubGlobal( + "fetch", + vi.fn((_url: unknown, init?: RequestInit) => + init?.method ? pending.promise : Promise.resolve(Response.json(snapshot)), + ), + ); + const view = renderHook(useAccountData, { wrapper }); + await waitFor(() => expect(view.result.current.ready).toBe(true)); + let operation!: Promise; + act(() => { + operation = view.result.current.createFavouriteSet("Ward round"); + }); + await act(async () => {}); + act(() => { + switchAccount(); + view.rerender(); + }); + await waitFor(() => expect(view.result.current.ready).toBe(true)); + await act(async () => { + pending.resolve(Response.json({ version: 1, set: setA }, { status })); + await operation; + }); + expect(view.result.current.favouriteSets).toEqual([]); + expect(view.result.current.error).toBeNull(); + }); + } +}); diff --git a/tests/fixtures/site-content/static-manifest-baseline.json b/tests/fixtures/site-content/static-manifest-baseline.json index 5156d89cf8..63fbf4b7c4 100644 --- a/tests/fixtures/site-content/static-manifest-baseline.json +++ b/tests/fixtures/site-content/static-manifest-baseline.json @@ -1,8 +1,8 @@ { "version": "clinical-kb-site-static-manifest-v1", - "gitSha": "16832faffe459985b3e176f683aa4f9c9094c5cd", + "gitSha": "54559adabe5b51396c2d1c0eb395f2cf79b43fbf", "registryVersion": "site-content-registry-v1", - "generatedAt": "2026-10-17T17:23:05.499Z", + "generatedAt": "2026-10-18T05:48:14.293Z", "records": [ { "logicalId": "calculators:auditc", @@ -2725,8 +2725,8 @@ "logicalId": "factsheets:bipolar", "domain": "factsheets", "route": "/factsheets/bipolar", - "contentHash": "a92159da9b9b41939644e2c86ac0166722a3e7cffd6f55f87f69ce9887fb7efd", - "lineageDigest": "2d4b2afae02c62d3880f6c802c6b38484038842eaf444f8dcfb6447fc0a78219", + "contentHash": "10fe0b3c731f8eea62e0d40275704b8cf0332785f43a36f841f5b64485e1f919", + "lineageDigest": "6dcdac238929ae64ebe756764793dd58084bc7e969402c52bb0e5968398ad7f7", "validationStatus": "locally_reviewed", "sourceStatus": "current", "eligible": true, @@ -2736,8 +2736,8 @@ "logicalId": "factsheets:cbt", "domain": "factsheets", "route": "/factsheets/cbt", - "contentHash": "b30d24a8c7081f1b978c7a2c62e77048253402b067b019ccdb0b9c67bd3244a4", - "lineageDigest": "6856e31c0c8725843e38b216f87ac1d4739bd20f32b5500b62f676cb604b61fd", + "contentHash": "6895f5f50c833f1cfb3c801e6bbcd52d69a9d8ee60e314eaa6a89d0e07ed4332", + "lineageDigest": "e3e1e557007c5e72cc6a814a775974e0227eb5d7afbc19bf0327a8d0e0589bcd", "validationStatus": "locally_reviewed", "sourceStatus": "current", "eligible": true, @@ -2747,8 +2747,8 @@ "logicalId": "factsheets:depression", "domain": "factsheets", "route": "/factsheets/depression", - "contentHash": "d40a9b04bda2ef36c57b97deefb93e050ea8f88a25ef245ec72e2871803959f0", - "lineageDigest": "dac713b7f938eb021429cc79e8327161023fac6757322be77a72174c2ac8f74c", + "contentHash": "9e66c7567b708025050a635b4e65725863444eb81663292a722b9d277184f5e3", + "lineageDigest": "01ec9a7bd8cf54713db4a65e9cdf20aa385a8fe6161f52bd0291ce9b72687694", "validationStatus": "locally_reviewed", "sourceStatus": "current", "eligible": true, @@ -2758,8 +2758,8 @@ "logicalId": "factsheets:escitalopram", "domain": "factsheets", "route": "/factsheets/escitalopram", - "contentHash": "7669ae21c45903ee01fe0e9059ebd6113195f4562e4735d94d407f6503ed3d5b", - "lineageDigest": "d8b214dcc3d5a981ebce8576e9b43cded6a308c680b2cba64f2539ae8f442133", + "contentHash": "165f7f11b7baaf7f5c4807e901b2f06f5f4815160f193dca230ee2bb8636ff50", + "lineageDigest": "d061e4d83767d4bb35beaec22deffde9f2d81d0e09ab25efdcdb62077f0701a8", "validationStatus": "locally_reviewed", "sourceStatus": "current", "eligible": true, @@ -2769,8 +2769,8 @@ "logicalId": "factsheets:gad", "domain": "factsheets", "route": "/factsheets/gad", - "contentHash": "74efb9e5959a492e49f16cf27c498a861c27e19d258803b597b2e1a7ecfdeedd", - "lineageDigest": "25db1350b99ae8a773813751dd749714d16cbb1161afa27be1e47bcdfc95cb71", + "contentHash": "89d091766b9c961f5dbca7aee46f78bd8932b499fa21f89a1838d6ce49ea47f3", + "lineageDigest": "7b60809b4dba169a59637f762b2518fa121959c73ee034bd1599172fdb6405d0", "validationStatus": "locally_reviewed", "sourceStatus": "current", "eligible": true, @@ -2780,8 +2780,8 @@ "logicalId": "factsheets:lithium-monitoring", "domain": "factsheets", "route": "/factsheets/lithium-monitoring", - "contentHash": "ed1486067712961fcf401462f53f882270604649d370f5d4060e9480c377fd79", - "lineageDigest": "da0ce7940af767645ee14202b20632be33cc646c442ddd836bcf7d26e1a919af", + "contentHash": "e3691a75f2805cb8d1929698d3a6c67d4826bd6a3c74c0b07bcb76dd20a5f969", + "lineageDigest": "4c0252f50958f1af3475500165dd9254646316dc6b1b8642956a58c3d72afb5d", "validationStatus": "locally_reviewed", "sourceStatus": "current", "eligible": true, @@ -2791,8 +2791,8 @@ "logicalId": "factsheets:sertraline", "domain": "factsheets", "route": "/factsheets/sertraline", - "contentHash": "9d6334907b4036de6b69c65560295642f1ec695dd8cd0c9d87c37871ad3864aa", - "lineageDigest": "955225cffd0d052a251e1b273231efe484dd9c060135fc2c072323737d34c4f9", + "contentHash": "d16c9433b37fd252d8b6b357f65739faf03cd9b1abab683cb0a03241e03941ac", + "lineageDigest": "5a4673b20d5b4ec60fdfdc61a5d0315d26e009e3c263d3d4452bf4ca82d14afc", "validationStatus": "locally_reviewed", "sourceStatus": "current", "eligible": true, @@ -2802,8 +2802,8 @@ "logicalId": "factsheets:ssri", "domain": "factsheets", "route": "/factsheets/ssri", - "contentHash": "364bf348c517e1ff6d946ae3a48f8b6165dcec712610edc525d0aac6ee1cc01d", - "lineageDigest": "7d9f793f51a4a0ce369f74c4191edc5109f59eb83c8b7610c94a6e3262a9fcac", + "contentHash": "c77b329227553d9296f0369258bf5006f4082fc948931560b393176d64b714ed", + "lineageDigest": "0aa342e6f624da2edba359b747d433aed012feca3e3bded14b14ad982308e9fc", "validationStatus": "locally_reviewed", "sourceStatus": "current", "eligible": true, @@ -11896,5 +11896,5 @@ "exclusionReason": null } ], - "staticManifestDigest": "c2eab707d745c5ff7075a1f0b124ea709fabd47a6f4d89797027b55705e10ae1" + "staticManifestDigest": "57218410ef7412d5cae9056a8dee8b09f38bf0f133a311a130ceb8ad37529941" } diff --git a/tests/hosted-migration-role-guard.test.ts b/tests/hosted-migration-role-guard.test.ts index bed49122bc..4b41397877 100644 --- a/tests/hosted-migration-role-guard.test.ts +++ b/tests/hosted-migration-role-guard.test.ts @@ -515,7 +515,7 @@ describe("hosted migration-role guard", { timeout: 60_000 }, () => { const runbook = readFileSync("docs/disaster-recovery-runbook.md", "utf8"); expect(generator).toContain("pg_catalog.pg_get_userbyid(nspowner)"); - expect(generator).toContain("psql(storageSchemaOwner, scaffoldSql)"); + expect(generator).toContain("await sql(storageSchemaOwner, inputs.scaffoldSql)"); expect(runbook).toContain("storage_owner="); expect(runbook).toContain('psql -U "${storage_owner}"'); expect(generator).not.toContain(RESERVED_HOSTED_ROLE); diff --git a/tests/ingestion-recovery.test.ts b/tests/ingestion-recovery.test.ts index 005b20730b..0275041f00 100644 --- a/tests/ingestion-recovery.test.ts +++ b/tests/ingestion-recovery.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildIngestionRecoveryPlan, + applyIngestionRecoveryActions, reconcileIngestionRecoveryPlan, INGESTION_RECOVERY_JOB_STATUSES, isFreshProcessingJob, @@ -11,6 +12,42 @@ import { describe("ingestion queue recovery planning", () => { const now = new Date("2026-06-15T00:00:00.000Z"); + it("keeps a current lease even when the legacy lock timestamp is old", () => { + const plan = buildIngestionRecoveryPlan({ + now, + staleAfterMinutes: 45, + jobs: [ + { + id: "current", + document_id: "doc", + status: "processing", + locked_at: "2026-06-14T20:00:00Z", + lease_expires_at: "2026-06-15T00:10:00Z", + documents: { status: "processing" }, + }, + ], + }); + expect(plan.actions).toEqual([]); + }); + + it("recovers an expired final attempt even with a recent legacy timestamp", () => { + const plan = buildIngestionRecoveryPlan({ + now, + staleAfterMinutes: 45, + jobs: [ + { + id: "expired", + document_id: "doc", + status: "processing", + locked_at: "2026-06-14T23:59:00Z", + lease_expires_at: "2026-06-14T23:59:59Z", + documents: { status: "processing" }, + }, + ], + }); + expect(plan.actions).toEqual([{ action: "retry", jobId: "expired", documentId: "doc", resetDocument: false }]); + }); + it("loads pending jobs alongside failed and processing siblings", () => { expect(INGESTION_RECOVERY_JOB_STATUSES).toEqual(["pending", "processing", "failed"]); }); @@ -37,7 +74,7 @@ describe("ingestion queue recovery planning", () => { }); expect(plan.retryCount).toBe(2); - expect(plan.resetDocumentIds.sort()).toEqual(["doc-a", "doc-b"]); + expect(plan.resetDocumentIds).toEqual([]); }); it("supersedes obsolete failed jobs for already indexed documents", () => { @@ -233,6 +270,8 @@ describe("ingestion recovery plan reconciliation (R20/R21)", () => { expected: { jobStatus: "processing", jobLockedAt: "2026-06-14T22:00:00.000Z", + jobLeaseToken: null, + jobLeaseExpiresAt: null, documentStatus: "processing", documentOwnerId: "owner-1", }, @@ -302,3 +341,44 @@ describe("ingestion recovery plan reconciliation (R20/R21)", () => { expect(applicable.map((entry) => entry.action.action)).toEqual(["supersede", "retry"]); }); }); + +describe("shared operator recovery application", () => { + const expected = { + jobStatus: "failed", + jobLockedAt: null, + jobLeaseToken: "lease", + jobLeaseExpiresAt: null, + documentStatus: "failed", + documentOwnerId: "owner", + }; + const action = { action: "retry" as const, jobId: "job", documentId: "doc", resetDocument: false }; + it("passes the complete observed authority snapshot to SQL and respects ownership loss", async () => { + const calls: unknown[] = []; + const client = { + rpc: async (...args: unknown[]) => { + calls.push(args); + return { data: { ok: false, reason: "active_lease" }, error: null }; + }, + }; + expect(await applyIngestionRecoveryActions(client, [{ action, expected }])).toEqual([ + { action, applied: false, reason: "active_lease" }, + ]); + expect(calls).toEqual([["recover_ingestion_job", { p_job_id: "job", p_action: "retry", p_expected: expected }]]); + }); + it("stops before any follow-up operation when required recovery SQL is unavailable", async () => { + let calls = 0; + const client = { + rpc: async () => { + calls += 1; + return { data: null, error: { message: "Missing recovery RPC" } }; + }, + }; + await expect( + applyIngestionRecoveryActions(client, [ + { action, expected }, + { action, expected }, + ]), + ).rejects.toThrow("Missing recovery RPC"); + expect(calls).toBe(1); + }); +}); diff --git a/tests/instrumentation.test.ts b/tests/instrumentation.test.ts index 21089983b9..4b7e8d3994 100644 --- a/tests/instrumentation.test.ts +++ b/tests/instrumentation.test.ts @@ -1,4 +1,58 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRequire } from "node:module"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Exercise the installed Next webpack parser/DefinePlugin, rather than only +// calling register() with Vitest's runtime import mocks. A negative early return +// can be safe at execution time while webpack still resolves later Node imports +// into its Edge graph. Externalizing dependencies records that resolution without +// executing boot work, starting timers or reaching any provider. +async function compiledRuntimeImports(runtime: "nodejs" | "edge") { + type Stats = { toJson: (options: { all: boolean; errors: boolean }) => { errors?: unknown[] } }; + type Compiler = { close: (callback: (error?: Error | null) => void) => void }; + type ExternalCallback = (error?: Error | null, external?: string) => void; + const { webpack } = createRequire(import.meta.url)("next/dist/compiled/webpack/webpack") as { + webpack: ((config: unknown, callback: (error: Error | null, stats?: Stats) => void) => Compiler) & { + DefinePlugin: new (definitions: Record) => unknown; + }; + }; + const directory = await mkdtemp(join(tmpdir(), "instrumentation-runtime-")); + const imports = new Set(); + try { + await new Promise((resolve, reject) => { + const compiler = webpack( + { + mode: "none", + target: "webworker", + entry: join(process.cwd(), "src/instrumentation.ts"), + output: { path: directory, filename: "instrumentation.js" }, + plugins: [new webpack.DefinePlugin({ "process.env.NEXT_RUNTIME": JSON.stringify(runtime) })], + externals: [ + ({ request }: { request?: string }, callback: ExternalCallback) => { + if (request?.startsWith("@/") || request?.startsWith("./sentry.")) { + imports.add(request); + callback(null, `commonjs ${request}`); + } else callback(); + }, + ], + }, + (error, stats) => { + compiler.close((closeError) => { + if (error || closeError) return reject(error ?? closeError); + const errors = stats?.toJson({ all: false, errors: true }).errors ?? []; + if (errors.length) return reject(new Error(JSON.stringify(errors))); + resolve(); + }); + }, + ); + }); + return imports; + } finally { + await rm(directory, { recursive: true, force: true }); + } +} // The boot guard (src/instrumentation.ts) must refuse to start a production server // that is misconfigured (missing/mismatched Supabase or required OpenAI config), @@ -65,6 +119,29 @@ afterEach(() => { }); describe("instrumentation boot guard", () => { + it("excludes Node-only startup dependencies from the actual Edge webpack graph", async () => { + const imports = await compiledRuntimeImports("edge"); + expect(imports).toContain("./sentry.edge.config"); + for (const nodeOnly of [ + "./sentry.server.config", + "@/lib/env", + "@/lib/rag/rag-retrieval-variants", + "@/lib/site-content/warm-catalogue-caches", + "@/app/api/setup-status/route", + "@/lib/alerts/timed-sender", + "@/lib/supabase/admin", + ]) + expect(imports, `${nodeOnly} must not resolve in the Edge compilation`).not.toContain(nodeOnly); + }); + + it("retains Node boot dependencies in the actual Node webpack graph", async () => { + const imports = await compiledRuntimeImports("nodejs"); + expect(imports).toContain("./sentry.server.config"); + expect(imports).toContain("@/lib/alerts/timed-sender"); + expect(imports).toContain("@/lib/supabase/admin"); + expect(imports).not.toContain("./sentry.edge.config"); + }); + it("refuses to start a production server with missing Supabase config", async () => { const register = await loadRegister({ ...PRODUCTION_NODE }); await expect(register()).rejects.toThrow(/Missing server environment variables/); diff --git a/tests/open-shifts-zone.test.ts b/tests/open-shifts-zone.test.ts new file mode 100644 index 0000000000..055299669e --- /dev/null +++ b/tests/open-shifts-zone.test.ts @@ -0,0 +1,116 @@ +// Audit-only date-boundary probe; all times synthetic, no network. +import { beforeEach, expect, it, vi } from "vitest"; +const work = vi.hoisted(() => ({ zone: "Australia/Sydney" })); +vi.mock("@/lib/work-time/current-zone", () => ({ currentWorkTimeZone: () => work.zone })); +import { hoursMeter } from "@/lib/open-shifts/mine"; +import { groupPosted } from "@/lib/open-shifts/posted"; +import { changeBody, changeRisks, draftFor } from "@/components/teaching/organise-model"; +import { perthWallToIso } from "@/lib/roster/shifts/perth-time"; +import type { SessionSummary } from "@/lib/teaching/model"; +beforeEach(() => { + work.zone = "Australia/Sydney"; +}); + +it("counts the full shift from local midnight in the selected Sydney work zone", () => { + // 17 October 00:00-08:00 AEDT. The published meter covers 17-30 October, inclusive. + const shift = { + id: "synthetic-sydney", + startsAt: "2026-10-16T13:00:00Z", + endsAt: "2026-10-16T21:00:00Z", + kind: "day" as const, + }; + const meter = hoursMeter([shift], [], new Date("2026-10-17T01:00:00Z"), false); + expect(meter.from).toBe("2026-10-17"); + expect(meter.rostered).toBe(8); +}); + +it.each([ + ["Australia/Perth", "2026-10-17", 8], + ["Australia/Sydney", "2026-07-17", 8], + ["Australia/Sydney", "2026-10-04", 7], + ["Australia/Sydney", "2026-04-05", 9], + ["Australia/Adelaide", "2026-10-17", 8], + ["Australia/Darwin", "2026-10-17", 8], +])("counts actual elapsed hours from local midnight in %s on %s", (zone, date, hours) => { + work.zone = zone; + const startsAt = perthWallToIso(date, "00:00")!; + const endsAt = perthWallToIso(date, "08:00")!; + const meter = hoursMeter([{ id: "shift", startsAt, endsAt, kind: "day" }], [], new Date(startsAt), false); + expect(meter.from).toBe(date); + expect(meter.rostered).toBe(hours); +}); + +it.each(["Australia/Perth", "Australia/Sydney", "Australia/Adelaide"])( + "uses seven calendar days rather than fixed elapsed days in %s across DST", + (zone) => { + work.zone = zone; + const now = new Date(perthWallToIso("2026-10-01", "12:00")!); + const row = (date: string, time: string) => ({ + status: "approved" as const, + startsAt: perthWallToIso(date, time)!, + endsAt: perthWallToIso("2026-10-08", "08:00")!, + }); + const before = row("2026-10-07", "23:59"); + expect(groupPosted([before, row("2026-10-08", "00:00")], now).filledThisWeek).toEqual([before]); + }, +); + +it("does not post a Teaching move into a skipped Sydney clock time", () => { + const session = { + occurrenceId: "session", + serviceId: "service", + title: "Teaching", + startsAt: "2026-10-03T01:00:00Z", + endsAt: "2026-10-03T02:00:00Z", + venue: "Room", + status: "scheduled", + source: "teaching", + hasJoinLink: false, + } as SessionSummary; + const draft = { ...draftFor(session), date: "2026-10-04", startTime: "02:30" }; + expect(changeRisks(session, draft, [])).toEqual([ + expect.objectContaining({ rule: "time", text: expect.stringContaining("valid date and time") }), + ]); + expect(changeBody(session, draft).startsAt).toBe(session.startsAt); +}); + +it("keeps the original instant on either side of a repeated Sydney clock time for a room edit", () => { + const session = { + occurrenceId: "session", + serviceId: "service", + title: "Teaching", + startsAt: "2026-04-04T15:30:15Z", + endsAt: "2026-04-04T16:30:15Z", + venue: "Room A", + status: "scheduled", + source: "teaching", + hasJoinLink: false, + } as SessionSummary; + const body = changeBody(session, { ...draftFor(session), venue: "Room B" }); + expect(body.startsAt).toBe(session.startsAt); + expect(body.endsAt).toBe(session.endsAt); +}); +it("excludes an approved shift starting after the seven local days end", () => { + // Window advertised as next 7 days starts 17 Oct and ends at 24 Oct local midnight. + const shift = { status: "approved" as const, startsAt: "2026-10-23T14:00:00Z", endsAt: "2026-10-23T22:00:00Z" }; + // Starts 24 Oct 01:00 AEDT; fixed +08 boundary incorrectly admits it until 03:00 AEDT. + expect(groupPosted([shift], new Date("2026-10-17T01:00:00Z")).filledThisWeek).toEqual([]); +}); +it("preserves the selected Sydney clock time when moving only a Teaching session room", () => { + const session = { + occurrenceId: "synthetic-session", + serviceId: "synthetic-service", + title: "Synthetic teaching", + startsAt: "2026-10-17T01:00:00Z", + endsAt: "2026-10-17T02:00:00Z", + venue: "Synthetic room A", + status: "scheduled", + source: "teaching", + hasJoinLink: false, + isPresenter: false, + } as SessionSummary; + const draft = { ...draftFor(session), venue: "Synthetic room B" }; + expect(draft.startTime).toBe("12:00"); + // Changing just the room must not move noon Sydney to 15:00 Sydney. + expect(changeBody(session, draft).startsAt).toBe(session.startsAt); +}); diff --git a/tests/operational-alerts.test.ts b/tests/operational-alerts.test.ts index 364ba5da14..03fd787a2d 100644 --- a/tests/operational-alerts.test.ts +++ b/tests/operational-alerts.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; import { evaluateOperationalAlerts, summarizeOperationalAlerts } from "../scripts/lib/operational-alerts.mjs"; function snapshot(overrides = {}) { @@ -15,6 +16,23 @@ function snapshot(overrides = {}) { } describe("evaluateOperationalAlerts", () => { + it("gives every configured alert a runbook with diagnosis and recovery actions", () => { + const source = readFileSync("scripts/lib/operational-alerts.mjs", "utf8"); + const runbook = readFileSync("docs/observability-slos.md", "utf8"); + const codes = [...source.matchAll(/code: "(OPS_[A-Z_]+)"/g)].map((match) => match[1]); + expect(codes.length).toBeGreaterThan(0); + for (const code of codes) { + const section = runbook.split(`### \`${code}\``)[1]?.split(/\n###? /)[0]; + expect(section, `Missing runbook for ${code}`).toBeDefined(); + for (const action of ["Diagnose", "Acknowledge", "Recover", "Escalate"]) { + expect(section, `${code} needs ${action}`).toContain(`**${action}:**`); + } + } + expect(runbook).not.toContain("Only two of the"); + expect(runbook).toContain("latencyBudgetBreachRate"); + expect(runbook).toContain("at least 20"); + }); + it("warns only above the hybrid-RPC 0.5% boundary", () => { expect(evaluateOperationalAlerts(snapshot({ hybridRpcErrorRate: 0.005 }))).toEqual([]); expect(evaluateOperationalAlerts(snapshot({ hybridRpcErrorRate: 0.0051 }))).toMatchObject([ diff --git a/tests/rag-answer-fallback.test.ts b/tests/rag-answer-fallback.test.ts index 07b40804db..5e4d7839a8 100644 --- a/tests/rag-answer-fallback.test.ts +++ b/tests/rag-answer-fallback.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mockRagScopeRevision } from "./rag-cache-test-revision"; import clozapineThresholdChunks from "./fixtures/clozapine-threshold-source-chunks.json"; import lithiumLiveExcerpts from "./fixtures/lithium-monitoring-live-excerpts.json"; import { citationFromResult } from "../src/lib/citations"; @@ -114,6 +115,33 @@ class EmptyQuery implements PromiseLike<{ data: unknown[]; error: null }> { } } +type CacheDocumentFixture = { id: string; owner_id: string | null; metadata: unknown }; + +/** Revision RPC fixture filters the same owner/public/document population as the SQL. */ +function withScopeRevision(client: T, documents: readonly CacheDocumentFixture[]) { + const original = client as T & { rpc: (name: string, params?: Record) => unknown }; + return { + ...client, + rpc(name: string, params: Record) { + if (name !== "rag_cache_scope_revision_v1") return original.rpc(name, params); + const ids = params.p_document_ids as string[] | null; + const scoped = documents.filter( + (row) => + ((params.p_owner_id !== null && row.owner_id === params.p_owner_id) || + (row.owner_id === null && params.p_include_public === true)) && + (!ids?.length || ids.includes(row.id)), + ); + const response = { + abortSignal: () => response, + then(yes?: (value: { data: string; error: null }) => unknown, no?: (error: unknown) => unknown) { + return Promise.resolve({ data: mockRagScopeRevision(scoped), error: null }).then(yes, no); + }, + }; + return response; + }, + }; +} + type GeneratedAnswerPayload = { answer: string; grounded: boolean; @@ -353,28 +381,73 @@ async function answerFromTextSources( }; return query; }; + const cacheDocuments = [ + ...new Map( + governedSources.map((row) => [ + row.document_id, + { + id: row.document_id, + owner_id: null, + metadata: row.source_metadata, + }, + ]), + ).values(), + ]; + let sharedRows: Array> = []; const cacheQuery = (table: string) => { + const filters = new Map(); + let operation = "select"; + let inserted: Record | undefined; + const matches = (row: Record) => + [...filters].every(([key, value]) => + key === "expires_at" + ? String(row[key]) > String(value) + : Array.isArray(value) + ? value.includes(row[key]) + : row[key] === value, + ); + const result = () => { + if (table === "documents") return { data: cacheDocuments.filter(matches), error: null }; + if (operation === "delete") sharedRows = sharedRows.filter((row) => !matches(row)); + if (operation === "insert") sharedRows.push(inserted!); + return { data: [], error: null }; + }; const builder = { select: () => builder, - eq: () => builder, - is: () => builder, + eq: (key: string, value: unknown) => { + filters.set(key, value); + return builder; + }, + is: (key: string, value: unknown) => { + filters.set(key, value); + return builder; + }, or: () => builder, - in: () => builder, - gt: () => builder, + in: (key: string, value: unknown) => { + filters.set(key, value); + return builder; + }, + gt: (key: string, value: unknown) => { + filters.set(key, value); + return builder; + }, order: () => builder, limit: () => builder, abortSignal: () => builder, - delete: () => builder, - insert: () => builder, - maybeSingle: async () => ({ data: null, error: null }), - then: (resolve: (value: { data: unknown[]; error: null }) => unknown) => - Promise.resolve({ - data: - table === "documents" - ? [{ id: "agitation-doc", updated_at: "2026-08-30T00:00:00.000Z", metadata: {} }] - : [], - error: null, - }).then(resolve), + delete: () => { + operation = "delete"; + return builder; + }, + insert: (row: Record) => { + operation = "insert"; + inserted = structuredClone(row); + return builder; + }, + maybeSingle: async () => { + const row = sharedRows.find(matches); + return { data: row ? { payload: structuredClone(row.payload) } : null, error: null }; + }, + then: (resolve: (value: { data: unknown[]; error: null }) => unknown) => Promise.resolve(result()).then(resolve), }; return builder; }; @@ -434,25 +507,29 @@ async function answerFromTextSources( return query; }; vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - rpc, - from: vi.fn((table: string) => - table === "document_chunks" - ? options.governed || options.reviewedPolicyFixture - ? admissionQuery() - : adjacentQuery() - : table === "rag_queries" && options.captureLoggedRow - ? { - insert: vi.fn(async (row) => { - options.captureLoggedRow?.(row); - return { error: null }; - }), - } - : options.repeatRequest && ["documents", "rag_response_cache"].includes(table) - ? cacheQuery(table) - : new EmptyQuery(), + createAdminClient: () => + withScopeRevision( + { + rpc, + from: vi.fn((table: string) => + table === "document_chunks" + ? options.governed || options.reviewedPolicyFixture + ? admissionQuery() + : adjacentQuery() + : table === "rag_queries" && options.captureLoggedRow + ? { + insert: vi.fn(async (row) => { + options.captureLoggedRow?.(row); + return { error: null }; + }), + } + : options.repeatRequest && ["documents", "rag_response_cache"].includes(table) + ? cacheQuery(table) + : new EmptyQuery(), + ), + }, + cacheDocuments, ), - }), })); let generatedAnswerAttemptIndex = 0; const generateStructuredTextResult = vi.fn(async (input: string, _schema: unknown, providerOptions = {}) => { @@ -4906,7 +4983,7 @@ describe("RAG structured-output fallback", () => { } }); - it("does not propagate an originating request's abort to a coalesced concurrent caller", async () => { + it("keeps an already-aborted request separate while live callers coalesce onto one fresh origination", async () => { vi.stubEnv("OPENAI_API_KEY", "test-key"); vi.stubEnv("RAG_SEARCH_CACHE_TTL_MS", "0"); vi.stubEnv("RAG_ANSWER_CACHE_TTL_MS", "300000"); @@ -4936,7 +5013,11 @@ describe("RAG structured-output fallback", () => { })); vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ rpc, from: vi.fn(() => new EmptyQuery()) }), + createAdminClient: () => + withScopeRevision( + { rpc, from: vi.fn(() => new EmptyQuery()) }, + sources.map((row) => ({ id: row.document_id, owner_id: null, metadata: row.source_metadata })), + ), })); vi.doMock("@/lib/openai", () => ({ embedTextWithTelemetry: vi.fn(async () => ({ embedding: [0.1, 0.2, 0.3], cacheHit: false })), @@ -4945,7 +5026,7 @@ describe("RAG structured-output fallback", () => { const { answerQuestionWithScope } = await import("../src/lib/rag/rag"); - // Originating request aborts before it can produce an answer; its shared in-flight promise rejects. + // This request is already aborted and never enters the in-flight map. const controller = new AbortController(); controller.abort(); const first = answerQuestionWithScope({ @@ -4954,7 +5035,7 @@ describe("RAG structured-output fallback", () => { logQuery: false, signal: controller.signal, }); - // Two identical waiters coalesce onto the (now-doomed) in-flight promise. + // The two live callers must share one independently admitted origination. const second = answerQuestionWithScope({ query: "Summarize inpatient approach", ownerId: "owner-1", @@ -4968,11 +5049,12 @@ describe("RAG structured-output fallback", () => { // The originator's failure stays with the originator... await expect(first).rejects.toBeTruthy(); - // ...and the coalesced caller still gets a real, independently generated answer rather than a 500. + // ...and the live callers get one independently generated answer rather than a 500. const [secondAnswer, thirdAnswer] = await Promise.all([second, third]); expect(secondAnswer.openAIRequestIds).toEqual(["req_independent"]); expect(thirdAnswer.openAIRequestIds).toEqual(["req_independent"]); expect(secondAnswer.routingReason ?? "").not.toContain("answer_inflight_coalesced"); + expect(thirdAnswer.routingReason).toContain("answer_inflight_coalesced"); // It ran its OWN pipeline (search + generation once) instead of cloning the failed one. expect(generateStructuredTextResult).toHaveBeenCalledTimes(1); expect(rpc.mock.calls.filter(([name]) => name === "match_document_chunks_text_v2")).toHaveLength(1); @@ -5020,7 +5102,11 @@ describe("RAG structured-output fallback", () => { }); vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ rpc, from: vi.fn(() => new EmptyQuery()) }), + createAdminClient: () => + withScopeRevision( + { rpc, from: vi.fn(() => new EmptyQuery()) }, + sources.map((row) => ({ id: row.document_id, owner_id: null, metadata: row.source_metadata })), + ), })); vi.doMock("@/lib/openai", () => ({ embedTextWithTelemetry: vi.fn(async () => ({ embedding: [0.1, 0.2, 0.3], cacheHit: false })), @@ -5036,12 +5122,20 @@ describe("RAG structured-output fallback", () => { await generationStarted; const controller = new AbortController(); + let markJoined!: () => void; + const joined = new Promise((resolve) => { + markJoined = resolve; + }); const second = answerQuestionWithScope({ query: "Summarize inpatient approach", ownerId: "owner-waiter-cancel", logQuery: false, signal: controller.signal, + onProgress: (event) => { + if (event.reason === "answer_inflight_coalesced") markJoined(); + }, }); + await joined; const reason = new DOMException("waiter left", "AbortError"); controller.abort(reason); await expect(second).rejects.toBe(reason); diff --git a/tests/rag-cache-governance-revocation.test.ts b/tests/rag-cache-governance-revocation.test.ts new file mode 100644 index 0000000000..a968663723 --- /dev/null +++ b/tests/rag-cache-governance-revocation.test.ts @@ -0,0 +1,505 @@ +import { createHash } from "node:crypto"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { SearchTelemetry } from "../src/lib/rag/rag-contracts"; +import type { RagAnswer, SearchResult } from "../src/lib/types"; + +const ownerId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const documentId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; +const args = { query: "monitoring pathway", documentId, ownerId, accessScope: { ownerId, includePublic: true } }; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function source(): SearchResult { + return { + id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + document_id: documentId, + title: "Synthetic guideline", + file_name: "synthetic.txt", + page_number: 1, + chunk_index: 0, + section_heading: "Monitoring", + content: "Use the current monitoring pathway.", + image_ids: [], + images: [], + similarity: 0.9, + source_metadata: { + source_title: "Synthetic guideline", + publisher: "WA Health", + publisher_code: "WA_HEALTH", + jurisdiction: "Australia/WA", + version: null, + publication_date: null, + review_date: null, + uploaded_at: null, + indexed_at: "2026-10-15T00:00:00.000Z", + uploaded_by: null, + document_status: "current", + clinical_validation_status: "approved", + extraction_quality: "good", + }, + }; +} + +function answer(): RagAnswer { + const evidence = source(); + return { + answer: evidence.content, + grounded: true, + confidence: "high", + sources: [evidence], + citations: [ + { + chunk_id: evidence.id, + document_id: documentId, + title: evidence.title, + file_name: evidence.file_name, + page_number: 1, + chunk_index: 0, + }, + ], + }; +} + +const telemetry = { + search_cache_hit: false, + query_class: "document_lookup", + text_fast_path_latency_ms: 0, + embedding_skipped: true, + embedding_latency_ms: 0, + embedding_cache_hit: false, + supabase_rpc_latency_ms: 0, + rerank_latency_ms: 0, +} as SearchTelemetry; + +/** Synthetic DB with actual matching cache-row filters, never a provider connection. */ +function installStorage() { + const document = { + id: documentId, + owner_id: ownerId as string | null, + status: "indexed", + updated_at: "2026-10-16T00:00:00.000Z", + metadata: { + indexed_at: "2026-10-15T00:00:00.000Z", + index_generation_id: "generation-1", + document_status: "current", + clinical_validation_status: "approved", + }, + }; + const state = { + documents: [document], + rows: [] as Array>, + failDelete: false, + failRevision: false, + malformedRevision: false, + failedDeletes: 0, + inserts: 0, + sharedReads: 0, + pendingInserts: 0, + emptyRetrieval: false, + sharedReadGate: undefined as Promise | undefined, + sharedInsertGate: undefined as Promise | undefined, + revisionReadGate: undefined as Promise | undefined, + }; + function scopeDocuments(params: Record) { + const ids = params.p_document_ids as string[] | null; + return state.documents.filter( + (row) => + ((row.owner_id === params.p_owner_id && params.p_owner_id !== null) || + (row.owner_id === null && params.p_include_public === true)) && + (!ids?.length || ids.includes(row.id)), + ); + } + const rpc = vi.fn(async (name: string, params: Record) => { + if (name === "record_source_review" || name === "record_source_review_v2") { + document.metadata.document_status = "outdated"; + + document.updated_at = "2026-10-17T00:00:00.000Z"; + return { data: { id: "review-1" }, error: null }; + } + if (name !== "rag_cache_scope_revision_v1") { + if (state.emptyRetrieval) return { data: [], error: null }; + throw new Error(`Unexpected offline RPC: ${name}`); + } + const rows = scopeDocuments(params).sort((left, right) => left.id.localeCompare(right.id)); + const revision = `rag-cache-scope-v1:${createHash("md5").update(JSON.stringify(rows)).digest("hex")}`; + await state.revisionReadGate; + return state.failRevision + ? { data: null, error: { code: "PGRST202", message: "RPC unavailable" } } + : { data: state.malformedRevision ? "legacy-unversioned-stamp" : revision, error: null }; + }); + const from = vi.fn((table: string) => { + const filters: Record = {}; + let operation = "select"; + let inserted: Record | undefined; + let maxRows = 1; + const builder: Record = {}; + for (const name of ["select", "or", "order", "abortSignal"]) builder[name] = () => builder; + for (const name of ["eq", "is", "in", "gt"]) + builder[name] = (key: string, value: unknown) => { + filters[key] = value; + return builder; + }; + builder.limit = (limit: number) => { + maxRows = limit; + return builder; + }; + builder.delete = () => { + operation = "delete"; + return builder; + }; + builder.insert = (row: Record) => { + operation = "insert"; + inserted = structuredClone(row); + return builder; + }; + function matches(row: Record) { + return Object.entries(filters).every(([key, value]) => + key === "expires_at" + ? String(row[key]) > String(value) + : Array.isArray(value) + ? value.includes(row[key]) + : row[key] === value, + ); + } + async function result(single: boolean) { + if (table === "documents") { + const docs = state.documents + .filter(matches) + .sort((left, right) => right.updated_at.localeCompare(left.updated_at)); + return { data: structuredClone(single ? (docs[0] ?? null) : docs.slice(0, maxRows)), error: null }; + } + if (table !== "rag_response_cache") { + if (state.emptyRetrieval) return { data: single ? null : [], error: null }; + throw new Error(`Unexpected offline table: ${table}`); + } + if (operation === "delete") { + if (state.failDelete) { + state.failedDeletes++; + return { data: null, error: { code: "57014", message: "synthetic delete timeout" } }; + } + state.rows = state.rows.filter((row) => !matches(row)); + } else if (operation === "insert") { + state.pendingInserts++; + await state.sharedInsertGate; + state.rows.push(inserted!); + state.inserts++; + } else { + const row = state.rows.find(matches); + const data = row ? { payload: structuredClone(row.payload) } : null; + state.sharedReads++; + await state.sharedReadGate; + return { data, error: null }; + } + return { data: null, error: null }; + } + builder.maybeSingle = () => result(true); + builder.then = (fulfilled?: (result: unknown) => unknown, rejected?: (reason: unknown) => unknown) => + result(false).then(fulfilled, rejected); + return builder; + }); + vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => ({ from, rpc }) })); + return { document, state, rpc, from }; +} + +afterEach(() => { + vi.doUnmock("@/lib/env"); + vi.doUnmock("@/lib/supabase/admin"); + vi.doUnmock("@/lib/supabase/auth"); + vi.doUnmock("@/lib/api-rate-limit"); + vi.resetModules(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +async function setup() { + vi.resetModules(); + vi.stubEnv("NEXT_PUBLIC_DEMO_MODE", "false"); + for (const key of ["RAG_ANSWER_CACHE_TTL_MS", "RAG_SEARCH_CACHE_TTL_MS"]) vi.stubEnv(key, "60000"); + for (const key of ["RAG_ANSWER_CACHE_SIZE", "RAG_SEARCH_CACHE_SIZE"]) vi.stubEnv(key, "100"); + vi.doMock("@/lib/env", async (importOriginal) => ({ + ...(await importOriginal()), + isDemoMode: () => false, + })); + const storage = installStorage(); + const cache = await import("../src/lib/rag/rag-cache"); + return { ...storage, cache }; +} + +async function seedBoth( + cache: Awaited>["cache"], + state: Awaited>["state"], +) { + await cache.setCachedAnswer(args, answer()); + await cache.setCachedSearch(args, [source()], telemetry); + await vi.waitFor(() => expect(state.rows).toHaveLength(2)); +} + +describe("durable source-governance cache fence (F13)", () => { + it.each(["rejected", "decommissioned"])( + "rejects retained local and shared answer/search entries after %s review", + async (decision) => { + const { cache, state, document } = await setup(); + await seedBoth(cache, state); + const start = await cache.cacheIndexingVersion(args, { forceRefresh: true }); + expect(await cache.getCachedAnswer(args, Date.now())).not.toBeNull(); + expect(await cache.getCachedSearch(args, telemetry.query_class)).not.toBeNull(); + // A different replica records the review: no process-local invalidation here. + document.updated_at = "2026-10-17T00:00:00.000Z"; + document.metadata.document_status = "outdated"; + document.metadata = { ...document.metadata, governance_disposition: decision } as typeof document.metadata; + expect(await cache.getCachedAnswer(args, Date.now(), { indexingVersionAtRequestStart: start })).toBeNull(); + expect( + await cache.getCachedSearch(args, telemetry.query_class, [], { indexingVersionAtRequestStart: start }), + ).toBeNull(); + expect(await cache.getSharedCachedAnswer(args, Date.now(), { indexingVersionAtRequestStart: start })).toBeNull(); + expect( + await cache.getSharedCachedSearch(args, telemetry.query_class, [], { indexingVersionAtRequestStart: start }), + ).toMatchObject({ kind: "miss" }); + }, + ); + + it("keeps ordinary current-source hits and keeps the fresh-metadata refusal control", async () => { + const { cache, state } = await setup(); + await seedBoth(cache, state); + expect((await cache.getCachedAnswer(args, Date.now()))?.routingReason).toContain("answer_cache_hit"); + expect(await cache.getCachedSearch(args, telemetry.query_class)).not.toBeNull(); + expect((await cache.getSharedCachedAnswer(args, Date.now()))?.routingReason).toContain("shared_answer_cache_hit"); + expect(await cache.getSharedCachedSearch(args, telemetry.query_class)).toMatchObject({ kind: "hit" }); + const { buildGovernedAnswerClientResponse } = await import("../src/lib/answer-response"); + expect(buildGovernedAnswerClientResponse(answer()).refused).toBe(false); + const revoked = answer(); + revoked.sources[0].source_metadata!.document_status = "outdated"; + expect(buildGovernedAnswerClientResponse(revoked).refused).toBe(true); + }); + + it("composes the actual review route and invalidator safely when shared deletion returns an error", async () => { + const { cache, state, rpc } = await setup(); + await seedBoth(cache, state); + vi.doMock("@/lib/supabase/auth", () => ({ + AuthenticationError: class AuthenticationError extends Error {}, + requireAuthenticatedUser: async () => ({ id: ownerId }), + unauthorizedResponse: vi.fn(), + })); + vi.doMock("@/lib/api-rate-limit", () => ({ + consumeApiRateLimit: async () => ({ limited: false }), + rateLimitJsonResponse: vi.fn(), + })); + state.failDelete = true; + const { POST } = await import("../src/app/api/documents/[id]/reviews/route"); + const response = await POST( + new Request(`http://localhost/api/documents/${documentId}/reviews`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ decision: "decommissioned", reason: "Synthetic governance review" }), + }), + { params: Promise.resolve({ id: documentId }) }, + ); + expect(response.status, JSON.stringify(await response.json())).toBe(201); + expect(rpc).toHaveBeenCalledWith("record_source_review", expect.objectContaining({ p_decision: "decommissioned" })); + await vi.waitFor(() => expect(state.failedDeletes).toBeGreaterThan(0)); + expect(state.rows).toHaveLength(2); + expect(await cache.getSharedCachedAnswer(args, Date.now())).toBeNull(); + expect(await cache.getSharedCachedSearch(args, telemetry.query_class)).toMatchObject({ kind: "miss" }); + }); + + it("detects governance changes to a non-latest scoped document and ignores another owner's document", async () => { + const { cache, state, document } = await setup(); + const other = structuredClone(document); + other.id = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"; + other.updated_at = "2026-10-18T00:00:00.000Z"; + state.documents.push(other); + const globalArgs = { ...args, documentId: undefined }; + const before = await cache.cacheIndexingVersion(globalArgs, { forceRefresh: true }); + document.metadata.document_status = "outdated"; + expect(await cache.cacheIndexingVersion(globalArgs, { forceRefresh: true })).not.toBe(before); + const after = await cache.cacheIndexingVersion(globalArgs, { forceRefresh: true }); + state.documents.push({ ...structuredClone(other), id: "foreign-document", owner_id: "foreign-owner" }); + expect(await cache.cacheIndexingVersion(globalArgs, { forceRefresh: true })).toBe(after); + }); + + it.each(["answer", "search"])("rechecks revision after an in-flight shared %s read", async (kind) => { + const { cache, state, document } = await setup(); + await seedBoth(cache, state); + const gate = deferred(); + state.sharedReadGate = gate.promise; + const read = + kind === "answer" + ? cache.getSharedCachedAnswer(args, Date.now()) + : cache.getSharedCachedSearch(args, telemetry.query_class); + await vi.waitFor(() => expect(state.sharedReads).toBe(1)); + document.metadata.document_status = "outdated"; + document.updated_at = "2026-10-17T00:00:00.000Z"; + gate.resolve(); + if (kind === "answer") expect(await read).toBeNull(); + else expect(await read).toMatchObject({ kind: "miss" }); + }); + + it.each(["answer", "search"])( + "cannot reuse a late shared %s write from a different replica after review", + async (kind) => { + const { cache, state, document } = await setup(); + const start = await cache.cacheIndexingVersion(args, { forceRefresh: true }); + const gate = deferred(); + state.sharedInsertGate = gate.promise; + if (kind === "answer") await cache.setCachedAnswer(args, answer(), { indexingVersionAtRetrievalStart: start }); + else await cache.setCachedSearch(args, [source()], telemetry, [], { indexingVersionAtRetrievalStart: start }); + await vi.waitFor(() => expect(state.pendingInserts).toBe(1)); + document.metadata.document_status = "outdated"; + document.updated_at = "2026-10-17T00:00:00.000Z"; + gate.resolve(); + await vi.waitFor(() => expect(state.inserts).toBe(1)); + expect(await cache.getSharedCachedAnswer(args, Date.now())).toBeNull(); + expect(await cache.getSharedCachedSearch(args, telemetry.query_class)).toMatchObject({ kind: "miss" }); + expect(await cache.getCachedAnswer(args, Date.now())).toBeNull(); + await cache.setCachedSearch(args, [source()], telemetry, [], { indexingVersionAtRetrievalStart: start }); + expect(await cache.getCachedSearch(args, telemetry.query_class)).toBeNull(); + }, + ); + + it.each(["answer", "search"])("fences an in-flight local %s read against same-process invalidation", async (kind) => { + const { cache, state } = await setup(); + await seedBoth(cache, state); + const gate = deferred(); + state.revisionReadGate = gate.promise; + const read = + kind === "answer" ? cache.getCachedAnswer(args, Date.now()) : cache.getCachedSearch(args, telemetry.query_class); + // No document mutation: the local epoch must fence mutations outside documents. + cache.invalidateRagCachesForOwner(ownerId); + gate.resolve(); + expect(await read).toBeNull(); + }); + + it("changes identity when a scoped document leaves the indexed corpus or is removed", async () => { + const { cache, state, document } = await setup(); + const before = await cache.cacheIndexingVersion(args); + document.status = "failed"; + const failed = await cache.cacheIndexingVersion(args); + expect(failed).not.toBe(before); + state.documents = []; + const removed = await cache.cacheIndexingVersion(args); + expect(removed).not.toBe(failed); + expect(await cache.cacheIndexingVersion(args)).toBe(removed); + }); + + it.each(["before_waiter", "while_waiting", "epoch_invalidation"])( + "does not deliver an older coalesced answer when review commits %s", + async (timing) => { + const { cache, document } = await setup(); + const { withRagProgrammeRollout } = await import("../src/lib/rag/rag-rollout"); + const { answerQuestionWithScope } = await import("../src/lib/rag/rag"); + const request = withRagProgrammeRollout({ ...args, logQuery: false }); + await cache.setCachedAnswer(request, answer()); + const originReady = deferred(); + const gate = deferred(); + let waiterJoined = false; + let waiterObservedProgress = false; + const origin = answerQuestionWithScope({ + ...request, + onProgress: async (progress) => { + if (progress.stage === "cached" && progress.reason?.includes("answer_cache_hit")) { + originReady.resolve(); + await gate.promise; + } + }, + }); + await originReady.promise; + const reviewAndSeedCurrentAnswer = async () => { + if (timing === "epoch_invalidation") cache.invalidateRagCachesForOwner(ownerId); + else { + document.metadata.document_status = "outdated"; + document.updated_at = "2026-10-17T00:00:00.000Z"; + } + await cache.setCachedAnswer(request, { + answer: "Fresh evidence is required after source review.", + grounded: false, + confidence: "unsupported", + sources: [], + citations: [], + }); + }; + if (timing === "before_waiter") await reviewAndSeedCurrentAnswer(); + const waiter = answerQuestionWithScope({ + ...request, + onProgress: (progress) => { + waiterObservedProgress = true; + waiterJoined ||= progress.reason?.includes("answer_inflight_coalesced") === true; + }, + }); + try { + await vi.waitFor(() => expect(timing !== "before_waiter" ? waiterJoined : waiterObservedProgress).toBe(true)); + if (timing !== "before_waiter") await reviewAndSeedCurrentAnswer(); + } finally { + gate.resolve(); + } + const delivered = await waiter; + await origin; + expect(delivered.sources).toEqual([]); + expect(delivered.answer).not.toBe(answer().answer); + expect(delivered.routingReason).not.toContain("answer_inflight_coalesced"); + }, + ); + + it.each(["missing", "malformed"])("fails cold when the durable revision RPC is %s, then recovers", async (mode) => { + const { cache, state } = await setup(); + await seedBoth(cache, state); + state.failRevision = mode === "missing"; + state.malformedRevision = mode === "malformed"; + expect(await cache.getCachedAnswer(args, Date.now())).toBeNull(); + expect(await cache.getCachedSearch(args, telemetry.query_class)).toBeNull(); + expect(await cache.getSharedCachedAnswer(args, Date.now())).toBeNull(); + expect(await cache.getSharedCachedSearch(args, telemetry.query_class)).toMatchObject({ kind: "miss" }); + await cache.setCachedAnswer({ ...args, query: "unavailable answer write" }, answer()); + await cache.setCachedSearch({ ...args, query: "unavailable search write" }, [source()], telemetry); + expect(state.inserts).toBe(2); + state.failRevision = false; + state.malformedRevision = false; + expect(await cache.getCachedAnswer(args, Date.now())).not.toBeNull(); + }); + + it("does not join an existing answer when its durable revision cannot be read", async () => { + vi.stubEnv("RAG_PROVIDER_MODE", "offline"); + const { cache, state } = await setup(); + const { withRagProgrammeRollout } = await import("../src/lib/rag/rag-rollout"); + const { answerQuestionWithScope } = await import("../src/lib/rag/rag"); + const request = withRagProgrammeRollout({ ...args, logQuery: false }); + await cache.setCachedAnswer(request, answer()); + const originReady = deferred(); + const gate = deferred(); + const origin = answerQuestionWithScope({ + ...request, + onProgress: async (progress) => { + if (progress.stage === "cached" && progress.reason?.includes("answer_cache_hit")) { + originReady.resolve(); + await gate.promise; + } + }, + }); + await originReady.promise; + state.failRevision = true; + state.emptyRetrieval = true; + let waiterStarted = false; + const waiter = answerQuestionWithScope({ + ...request, + onProgress: (progress) => { + waiterStarted ||= + progress.stage !== "cached" || progress.reason?.includes("answer_inflight_coalesced") === true; + }, + }); + try { + await vi.waitFor(() => expect(waiterStarted).toBe(true)); + } finally { + gate.resolve(); + } + const delivered = await waiter; + await origin; + expect(delivered.sources).toEqual([]); + expect(delivered.routingReason).not.toContain("answer_inflight_coalesced"); + expect(await cache.answerInflightCacheKey(request)).toBeNull(); + }); +}); diff --git a/tests/rag-cache-indexing-stamp.test.ts b/tests/rag-cache-indexing-stamp.test.ts index 8f60b42c2b..e3f9a861c0 100644 --- a/tests/rag-cache-indexing-stamp.test.ts +++ b/tests/rag-cache-indexing-stamp.test.ts @@ -1,3 +1,4 @@ +import { withMockRagScopeRevision } from "./rag-cache-test-revision"; /** * The indexing-stamp corpus-staleness guard (audit L21). * @@ -27,42 +28,43 @@ type DocumentsResult = { data: unknown; error: unknown }; function mockDocumentsQuery(result: () => DocumentsResult) { vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - from: vi.fn((table: string) => { - if (table !== "documents") { - return { - delete: vi.fn(() => ({ - eq: vi.fn(function eq(this: unknown) { - return this; - }), - is: vi.fn(function is(this: unknown) { - return this; - }), - in: vi.fn(function inFilter(this: unknown) { - return this; - }), - then: (onfulfilled?: (value: { data: null; error: null }) => unknown) => - Promise.resolve({ data: null, error: null }).then(onfulfilled), - })), - insert: vi.fn(async () => ({ data: null, error: null })), - select: vi.fn(() => ({ - eq: vi.fn(function eq(this: unknown) { - return this; - }), - then: (onfulfilled?: (value: { data: null; error: null }) => unknown) => - Promise.resolve({ data: null, error: null }).then(onfulfilled), - })), - }; - } - const builder: Record = {}; - for (const method of ["select", "eq", "is", "or", "in", "order", "limit", "abortSignal"]) { - builder[method] = vi.fn(() => builder); - } - builder.then = (onfulfilled?: (value: DocumentsResult) => unknown) => - Promise.resolve(result()).then(onfulfilled); - return builder; + createAdminClient: () => + withMockRagScopeRevision({ + from: vi.fn((table: string) => { + if (table !== "documents") { + return { + delete: vi.fn(() => ({ + eq: vi.fn(function eq(this: unknown) { + return this; + }), + is: vi.fn(function is(this: unknown) { + return this; + }), + in: vi.fn(function inFilter(this: unknown) { + return this; + }), + then: (onfulfilled?: (value: { data: null; error: null }) => unknown) => + Promise.resolve({ data: null, error: null }).then(onfulfilled), + })), + insert: vi.fn(async () => ({ data: null, error: null })), + select: vi.fn(() => ({ + eq: vi.fn(function eq(this: unknown) { + return this; + }), + then: (onfulfilled?: (value: { data: null; error: null }) => unknown) => + Promise.resolve({ data: null, error: null }).then(onfulfilled), + })), + }; + } + const builder: Record = {}; + for (const method of ["select", "eq", "is", "or", "in", "order", "limit", "abortSignal"]) { + builder[method] = vi.fn(() => builder); + } + builder.then = (onfulfilled?: (value: DocumentsResult) => unknown) => + Promise.resolve(result()).then(onfulfilled); + return builder; + }), }), - }), })); } @@ -85,7 +87,7 @@ describe("indexing-stamp error is not an empty corpus (L21)", () => { expect(errorStamp).not.toBe(emptyStamp); expect(errorStamp).toContain("index-stamp-unavailable"); - expect(emptyStamp).toContain("no-indexed-documents"); + expect(emptyStamp).toContain("rag-cache-scope-v1:"); }); it("does not write an answer to the cache under an unavailable stamp", async () => { diff --git a/tests/rag-cache-invalidation.test.ts b/tests/rag-cache-invalidation.test.ts index 7f29486f65..b61df96843 100644 --- a/tests/rag-cache-invalidation.test.ts +++ b/tests/rag-cache-invalidation.test.ts @@ -1,3 +1,4 @@ +import { withMockRagScopeRevision, mockRagScopeRevision } from "./rag-cache-test-revision"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { SearchChunksArgs, SearchTelemetry } from "../src/lib/rag/rag-contracts"; import type { RagAnswer, SearchResult } from "../src/lib/types"; @@ -149,7 +150,7 @@ function installAnswerCacheEntrypointHarness( data: name.endsWith("_v3") && options.admittedSource ? [options.admittedSource] : [], error: null, })); - vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => ({ from, rpc }) })); + vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => withMockRagScopeRevision({ from, rpc }) })); return { from, rpc, insertedRows, state }; } @@ -337,8 +338,8 @@ describe("RAG cache invalidation", () => { const { ragCacheDependencyVersion } = await import("../src/lib/rag/rag-cache"); // Main still writes the unsafe grounded/non-review_only shape under v24; extractive // review fallback lacks generation_fallback:, so getSharedCachedAnswer will not evict. - // v25 is the intentional invalidation — staying on v24 would reintroduce the blocker. - expect(ragCacheDependencyVersion).toBe("rag-cache-v25"); + // v26 retains v25's intentional invalidation and adds governance freshness. Staying on v24 would reintroduce the blocker. + expect(ragCacheDependencyVersion).toBe("rag-cache-v26"); expect(ragCacheDependencyVersion).not.toBe("rag-cache-v24"); }); @@ -540,7 +541,7 @@ describe("RAG cache invalidation", () => { }, publicSiteChangeEpoch: "1", pendingPublicSiteChangeCount: 0, - documentIndexGeneration: "index-v1", + documentIndexGeneration: "test-rag-version:rag-cache-scope-v1:" + "0".repeat(32), sourcePolicyVersion: "policy-v1", rolloutVersion: "rollout-v1", }, @@ -552,7 +553,7 @@ describe("RAG cache invalidation", () => { canaryBasisPoints: 10000, queryPlanVersion: "v1", sourcePolicyVersion: "policy-v1", - indexGeneration: "index-v1", + indexGeneration: "test-rag-version:rag-cache-scope-v1:" + "0".repeat(32), publicSiteContentReleaseId: args.ragRequestContext.snapshot.publicSiteContent.releaseId, publicSiteContentStaticManifestDigest: "a".repeat(64), publicSiteContentReleaseDigest: "c".repeat(64), @@ -680,7 +681,7 @@ describe("RAG cache invalidation", () => { expect((await cache.getCachedAnswer(args, Date.now()))?.answer).toBe("variant-" + index); expect((await cache.getSharedCachedAnswer(args, Date.now()))?.answer).toBe("variant-" + index); } - expect(rows.every((row) => row.dependency_version === "rag-cache-v25")).toBe(true); + expect(rows.every((row) => row.dependency_version === "rag-cache-v26")).toBe(true); }); it("P08C distinguishes full request suffixes in every answer identity without retaining request text", async () => { @@ -782,7 +783,7 @@ describe("RAG cache invalidation", () => { const from = vi.fn(() => { throw new Error("shadow cache writes must not touch storage"); }); - vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => ({ from }) })); + vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => withMockRagScopeRevision({ from }) })); const { getCachedAnswer, setCachedAnswer, setCachedSearch } = await import("../src/lib/rag/rag-cache"); const args = { @@ -836,9 +837,9 @@ describe("RAG cache invalidation", () => { // Ledger #ZK460W: must stay ahead of main's v24 while that namespace still holds // unsafe grounded/non-review_only review-fallback rows that lack generation_fallback:. - expect(ragCacheDependencyVersion).toBe("rag-cache-v25"); + expect(ragCacheDependencyVersion).toBe("rag-cache-v26"); expect(ragCacheDependencyVersion).not.toBe("rag-cache-v24"); - expect(scopedAnswerCacheKey(legacyV1)).toMatch(/^rag-cache-v25\|/); + expect(scopedAnswerCacheKey(legacyV1)).toMatch(/^rag-cache-v26\|/); expect(scopedAnswerCacheKey(legacyV1)).not.toBe(scopedAnswerCacheKey(shadowV1)); expect(scopedAnswerCacheKey(shadowV1)).not.toBe(scopedAnswerCacheKey(shadowV2)); const searchKeys = [legacyV1, shadowV1, shadowV2].map((args) => @@ -851,11 +852,12 @@ describe("RAG cache invalidation", () => { it("removes only the matching hashed site-aware answer and in-flight identities", async () => { vi.resetModules(); vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - from: () => ({ - delete: () => new DeleteQuery([]), + createAdminClient: () => + withMockRagScopeRevision({ + from: () => ({ + delete: () => new DeleteQuery([]), + }), }), - }), })); const { withRagRequestContext } = await import("../src/lib/rag/rag-context-snapshot"); @@ -905,16 +907,17 @@ describe("RAG cache invalidation", () => { const calls: FilterCall[][] = []; vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - from: vi.fn((table: string) => ({ - delete: vi.fn(() => { - expect(table).toBe("rag_response_cache"); - const queryCalls: FilterCall[] = []; - calls.push(queryCalls); - return new DeleteQuery(queryCalls); - }), - })), - }), + createAdminClient: () => + withMockRagScopeRevision({ + from: vi.fn((table: string) => ({ + delete: vi.fn(() => { + expect(table).toBe("rag_response_cache"); + const queryCalls: FilterCall[] = []; + calls.push(queryCalls); + return new DeleteQuery(queryCalls); + }), + })), + }), })); const { invalidateRagCachesForDocumentMutation } = await import("../src/lib/rag/rag"); @@ -946,41 +949,42 @@ describe("RAG cache invalidation", () => { const indexingVersionGate = new Promise((resolve) => { releaseIndexingVersion = resolve; }); - const indexingStamp = "rag-deep-memory-v2:doc-1:2026-07-01T00:00:00.000Z:"; + const indexingStamp = `rag-deep-memory-v2:${mockRagScopeRevision([{ id: "doc-1", updated_at: "2026-07-01T00:00:00.000Z", metadata: {} }])}`; vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - from: vi.fn((table: string) => { - if (table === "documents") { - const builder = { - select: vi.fn(() => builder), - eq: vi.fn(() => builder), - is: vi.fn(() => builder), - or: vi.fn(() => builder), - in: vi.fn(() => builder), - order: vi.fn(() => builder), - limit: vi.fn(() => builder), - abortSignal: vi.fn(() => builder), - then: (onfulfilled?: (value: { data: unknown; error: null }) => unknown) => - indexingVersionGate - .then(() => - Promise.resolve({ - data: [{ id: "doc-1", updated_at: "2026-07-01T00:00:00.000Z", metadata: {} }], - error: null, - }), - ) - .then(onfulfilled), - }; - return builder; - } + createAdminClient: () => + withMockRagScopeRevision({ + from: vi.fn((table: string) => { + if (table === "documents") { + const builder = { + select: vi.fn(() => builder), + eq: vi.fn(() => builder), + is: vi.fn(() => builder), + or: vi.fn(() => builder), + in: vi.fn(() => builder), + order: vi.fn(() => builder), + limit: vi.fn(() => builder), + abortSignal: vi.fn(() => builder), + then: (onfulfilled?: (value: { data: unknown; error: null }) => unknown) => + indexingVersionGate + .then(() => + Promise.resolve({ + data: [{ id: "doc-1", updated_at: "2026-07-01T00:00:00.000Z", metadata: {} }], + error: null, + }), + ) + .then(onfulfilled), + }; + return builder; + } - const deleteCalls: FilterCall[] = []; - return { - delete: vi.fn(() => new DeleteQuery(deleteCalls)), - insert: vi.fn(async () => ({ data: null, error: null })), - }; + const deleteCalls: FilterCall[] = []; + return { + delete: vi.fn(() => new DeleteQuery(deleteCalls)), + insert: vi.fn(async () => ({ data: null, error: null })), + }; + }), }), - }), })); const { getCachedAnswer, invalidateRagCachesForOwner, setCachedAnswer } = await import("../src/lib/rag/rag-cache"); @@ -995,7 +999,7 @@ describe("RAG cache invalidation", () => { }); // Simulate a table-fact / review mutation while the deferred promotion awaits - // its forced indexing-version read. Review metadata does not change that stamp. + // its forced document-revision read. A table-fact mutation is outside that revision. invalidateRagCachesForOwner(ownerId); releaseIndexingVersion?.(); await promotion; @@ -1015,41 +1019,42 @@ describe("RAG cache invalidation", () => { const indexingVersionGate = new Promise((resolve) => { releaseIndexingVersion = resolve; }); - const indexingStamp = "rag-deep-memory-v2:doc-1:2026-07-01T00:00:00.000Z:"; + const indexingStamp = `rag-deep-memory-v2:${mockRagScopeRevision([{ id: "doc-1", updated_at: "2026-07-01T00:00:00.000Z", metadata: {} }])}`; vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - from: vi.fn((table: string) => { - if (table === "documents") { - const builder = { - select: vi.fn(() => builder), - eq: vi.fn(() => builder), - is: vi.fn(() => builder), - or: vi.fn(() => builder), - in: vi.fn(() => builder), - order: vi.fn(() => builder), - limit: vi.fn(() => builder), - abortSignal: vi.fn(() => builder), - then: (onfulfilled?: (value: { data: unknown; error: null }) => unknown) => - indexingVersionGate - .then(() => - Promise.resolve({ - data: [{ id: "doc-1", updated_at: "2026-07-01T00:00:00.000Z", metadata: {} }], - error: null, - }), - ) - .then(onfulfilled), - }; - return builder; - } + createAdminClient: () => + withMockRagScopeRevision({ + from: vi.fn((table: string) => { + if (table === "documents") { + const builder = { + select: vi.fn(() => builder), + eq: vi.fn(() => builder), + is: vi.fn(() => builder), + or: vi.fn(() => builder), + in: vi.fn(() => builder), + order: vi.fn(() => builder), + limit: vi.fn(() => builder), + abortSignal: vi.fn(() => builder), + then: (onfulfilled?: (value: { data: unknown; error: null }) => unknown) => + indexingVersionGate + .then(() => + Promise.resolve({ + data: [{ id: "doc-1", updated_at: "2026-07-01T00:00:00.000Z", metadata: {} }], + error: null, + }), + ) + .then(onfulfilled), + }; + return builder; + } - const deleteCalls: FilterCall[] = []; - return { - delete: vi.fn(() => new DeleteQuery(deleteCalls)), - insert: vi.fn(async () => ({ data: null, error: null })), - }; + const deleteCalls: FilterCall[] = []; + return { + delete: vi.fn(() => new DeleteQuery(deleteCalls)), + insert: vi.fn(async () => ({ data: null, error: null })), + }; + }), }), - }), })); const { getCachedAnswer, invalidateRagCachesForOwner, setCachedAnswer } = await import("../src/lib/rag/rag-cache"); diff --git a/tests/rag-cache-lru.test.ts b/tests/rag-cache-lru.test.ts index e086162e2e..f626740294 100644 --- a/tests/rag-cache-lru.test.ts +++ b/tests/rag-cache-lru.test.ts @@ -1,3 +1,4 @@ +import { withMockRagScopeRevision } from "./rag-cache-test-revision"; /** * Process-local answer-cache eviction order (audit L134). * @@ -27,42 +28,43 @@ type DocumentsResult = { data: unknown; error: unknown }; function mockDocumentsQuery(result: () => DocumentsResult) { vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - from: vi.fn((table: string) => { - if (table !== "documents") { - return { - delete: vi.fn(() => ({ - eq: vi.fn(function eq(this: unknown) { - return this; - }), - is: vi.fn(function is(this: unknown) { - return this; - }), - in: vi.fn(function inFilter(this: unknown) { - return this; - }), - then: (onfulfilled?: (value: { data: null; error: null }) => unknown) => - Promise.resolve({ data: null, error: null }).then(onfulfilled), - })), - insert: vi.fn(async () => ({ data: null, error: null })), - select: vi.fn(() => ({ - eq: vi.fn(function eq(this: unknown) { - return this; - }), - then: (onfulfilled?: (value: { data: null; error: null }) => unknown) => - Promise.resolve({ data: null, error: null }).then(onfulfilled), - })), - }; - } - const builder: Record = {}; - for (const method of ["select", "eq", "is", "or", "in", "order", "limit", "abortSignal"]) { - builder[method] = vi.fn(() => builder); - } - builder.then = (onfulfilled?: (value: DocumentsResult) => unknown) => - Promise.resolve(result()).then(onfulfilled); - return builder; + createAdminClient: () => + withMockRagScopeRevision({ + from: vi.fn((table: string) => { + if (table !== "documents") { + return { + delete: vi.fn(() => ({ + eq: vi.fn(function eq(this: unknown) { + return this; + }), + is: vi.fn(function is(this: unknown) { + return this; + }), + in: vi.fn(function inFilter(this: unknown) { + return this; + }), + then: (onfulfilled?: (value: { data: null; error: null }) => unknown) => + Promise.resolve({ data: null, error: null }).then(onfulfilled), + })), + insert: vi.fn(async () => ({ data: null, error: null })), + select: vi.fn(() => ({ + eq: vi.fn(function eq(this: unknown) { + return this; + }), + then: (onfulfilled?: (value: { data: null; error: null }) => unknown) => + Promise.resolve({ data: null, error: null }).then(onfulfilled), + })), + }; + } + const builder: Record = {}; + for (const method of ["select", "eq", "is", "or", "in", "order", "limit", "abortSignal"]) { + builder[method] = vi.fn(() => builder); + } + builder.then = (onfulfilled?: (value: DocumentsResult) => unknown) => + Promise.resolve(result()).then(onfulfilled); + return builder; + }), }), - }), })); } diff --git a/tests/rag-cache-test-revision.ts b/tests/rag-cache-test-revision.ts new file mode 100644 index 0000000000..13cce47a26 --- /dev/null +++ b/tests/rag-cache-test-revision.ts @@ -0,0 +1,67 @@ +import { createHash } from "node:crypto"; + +/** Fixture adapter for suites that already model document reads, never a production fallback. */ +export function mockRagScopeRevision(rows: unknown) { + return `rag-cache-scope-v1:${createHash("md5").update(JSON.stringify(rows)).digest("hex")}`; +} + +type MockQuery = PromiseLike<{ data: unknown; error: unknown }> & { + select: (columns: string) => MockQuery; + eq: (column: string, value: unknown) => MockQuery; + is: (column: string, value: unknown) => MockQuery; + or: (filter: string) => MockQuery; + in: (column: string, value: unknown) => MockQuery; + order: (column: string, options: unknown) => MockQuery; + limit: (limit: number) => MockQuery; + abortSignal?: (signal: AbortSignal) => MockQuery; +}; + +export function withMockRagScopeRevision(client: T, fixedRevision?: string) { + const storage = client as T & { from: (table: string) => MockQuery; rpc?: (...args: unknown[]) => unknown }; + return { + ...client, + rpc(name: string, params: Record) { + if (name !== "rag_cache_scope_revision_v1") return storage.rpc?.(name, params); + if (fixedRevision) { + const response = { + abortSignal: () => response, + then(fulfilled?: (result: { data: string; error: null }) => unknown) { + return Promise.resolve({ data: fixedRevision, error: null }).then(fulfilled); + }, + }; + return response; + } + // These suites model one/current document. The dedicated F13 suite models + // complete-scope DB revision semantics and matching owner/document filters. + let query = storage + .from("documents") + .select("id,updated_at,metadata") + .eq("status", "indexed") + .order("updated_at", { ascending: false }) + .limit(1); + const owner = params.p_owner_id; + query = owner + ? params.p_include_public + ? query.or(`owner_id.eq.${owner},owner_id.is.null`) + : query.eq("owner_id", owner) + : query.is("owner_id", null); + const ids = params.p_document_ids as string[] | null; + if (ids?.length) query = query.in("id", ids); + const response = { + abortSignal(signal: AbortSignal) { + query = query.abortSignal?.(signal) ?? query; + return response; + }, + then( + fulfilled?: (result: { data: string | null; error: unknown }) => unknown, + rejected?: (reason: unknown) => unknown, + ) { + return Promise.resolve(query) + .then(({ data, error }) => ({ data: error ? null : mockRagScopeRevision(data), error })) + .then(fulfilled, rejected); + }, + }; + return response; + }, + }; +} diff --git a/tests/rag-shared-cache.test.ts b/tests/rag-shared-cache.test.ts index b633b66a8e..f9ba53773a 100644 --- a/tests/rag-shared-cache.test.ts +++ b/tests/rag-shared-cache.test.ts @@ -1,3 +1,4 @@ +import { withMockRagScopeRevision } from "./rag-cache-test-revision"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { SearchTelemetry } from "../src/lib/rag/rag-contracts"; @@ -65,7 +66,7 @@ describe("shared RAG search cache", () => { const from = vi.fn(() => { throw new Error("shadow must not access answer cache"); }); - vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => ({ from }) })); + vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => withMockRagScopeRevision({ from }) })); const cache = await import("../src/lib/rag/rag-cache"); const args = { query: "What is clozapine?", ownerId, ragQueryPlanMode: "shadow" as const }; const answer = { @@ -128,9 +129,10 @@ describe("shared RAG search cache", () => { vi.doMock("@/lib/deep-memory", () => ({ ragDeepMemoryVersion: "test-rag-version" })); vi.doMock("@/lib/clinical-search", () => ({ buildClinicalTextSearchQuery: (query: string) => query.trim() })); vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - from: (table: string) => (table === "documents" ? documentBuilder : responseBuilder), - }), + createAdminClient: () => + withMockRagScopeRevision({ + from: (table: string) => (table === "documents" ? documentBuilder : responseBuilder), + }), })); const { getCachedSearch, setCachedSearch } = await import("../src/lib/rag/rag-cache"); @@ -229,9 +231,10 @@ describe("shared RAG search cache", () => { vi.doMock("@/lib/deep-memory", () => ({ ragDeepMemoryVersion: "test-rag-version" })); vi.doMock("@/lib/clinical-search", () => ({ buildClinicalTextSearchQuery: (query: string) => query.trim() })); vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - from: (table: string) => (table === "documents" ? documentBuilder : responseBuilder), - }), + createAdminClient: () => + withMockRagScopeRevision({ + from: (table: string) => (table === "documents" ? documentBuilder : responseBuilder), + }), })); const { withRagRequestContext } = await import("../src/lib/rag/rag-context-snapshot"); @@ -331,7 +334,10 @@ describe("shared RAG search cache", () => { })); vi.doMock("@/lib/deep-memory", () => ({ ragDeepMemoryVersion: "test-rag-version" })); vi.doMock("@/lib/clinical-search", () => ({ buildClinicalTextSearchQuery: (query: string) => query.trim() })); - vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => ({ from: () => builder }) })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: () => + withMockRagScopeRevision({ from: () => builder }, "rag-cache-scope-v1:" + "0".repeat(32)), + })); const { withRagRequestContext } = await import("../src/lib/rag/rag-context-snapshot"); const { getSharedCachedAnswer, getSharedCachedSearch } = await import("../src/lib/rag/rag-cache"); @@ -360,7 +366,9 @@ describe("shared RAG search cache", () => { ragContextSnapshotInput: snapshotInput, }); - await getSharedCachedSearch(request, "table_threshold", [], { indexingVersionAtRequestStart: "index-v1" }); + await getSharedCachedSearch(request, "table_threshold", [], { + indexingVersionAtRequestStart: "test-rag-version:rag-cache-scope-v1:" + "0".repeat(32), + }); const firstSearchQuery = selectors.find( (selector) => selector.method === "eq" && selector.column === "normalized_query", )?.value; @@ -368,7 +376,9 @@ describe("shared RAG search cache", () => { expect(selectors).not.toContainEqual({ method: "eq", column: "owner_id", value: ownerId }); selectors.length = 0; - await getSharedCachedAnswer(request, Date.now(), { indexingVersionAtRequestStart: "index-v1" }); + await getSharedCachedAnswer(request, Date.now(), { + indexingVersionAtRequestStart: "test-rag-version:rag-cache-scope-v1:" + "0".repeat(32), + }); expect(selectors).toContainEqual({ method: "eq", column: "owner_id", value: ownerId }); selectors.length = 0; @@ -378,7 +388,9 @@ describe("shared RAG search cache", () => { accessScope: request.accessScope, ragContextSnapshotInput: { ...snapshotInput, publicSiteChangeEpoch: "2" }, }); - await getSharedCachedSearch(nextRequest, "table_threshold", [], { indexingVersionAtRequestStart: "index-v1" }); + await getSharedCachedSearch(nextRequest, "table_threshold", [], { + indexingVersionAtRequestStart: "test-rag-version:rag-cache-scope-v1:" + "0".repeat(32), + }); const nextSearchQuery = selectors.find( (selector) => selector.method === "eq" && selector.column === "normalized_query", )?.value; @@ -404,32 +416,35 @@ describe("shared RAG search cache", () => { buildClinicalTextSearchQuery: (query: string) => query.trim(), })); vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - from: (table: string) => { - const builder = { - select: () => builder, - eq: () => builder, - is: () => builder, - in: () => builder, - or: () => builder, - gt: () => builder, - order: () => builder, - limit: () => builder, - maybeSingle: async () => { - expect(table).toBe("rag_response_cache"); - sharedCacheReads += 1; - return { data: null, error: null }; - }, - then: (resolve: (value: { data: unknown[]; error: null }) => unknown) => - Promise.resolve({ - data: - table === "documents" ? [{ id: "doc-1", updated_at: "2026-07-14T00:00:00.000Z", metadata: {} }] : [], - error: null, - }).then(resolve), - }; - return builder; - }, - }), + createAdminClient: () => + withMockRagScopeRevision({ + from: (table: string) => { + const builder = { + select: () => builder, + eq: () => builder, + is: () => builder, + in: () => builder, + or: () => builder, + gt: () => builder, + order: () => builder, + limit: () => builder, + maybeSingle: async () => { + expect(table).toBe("rag_response_cache"); + sharedCacheReads += 1; + return { data: null, error: null }; + }, + then: (resolve: (value: { data: unknown[]; error: null }) => unknown) => + Promise.resolve({ + data: + table === "documents" + ? [{ id: "doc-1", updated_at: "2026-07-14T00:00:00.000Z", metadata: {} }] + : [], + error: null, + }).then(resolve), + }; + return builder; + }, + }), })); const { getSharedCachedSearch } = await import("../src/lib/rag/rag-cache"); @@ -460,39 +475,42 @@ describe("shared RAG search cache", () => { buildClinicalTextSearchQuery: (query: string) => query.trim(), })); vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - from: (table: string) => { - const builder = { - select: () => builder, - eq: () => builder, - is: () => builder, - in: () => builder, - or: () => builder, - gt: () => builder, - order: () => builder, - limit: () => builder, - maybeSingle: async () => { - if (table !== "rag_response_cache") return { data: null, error: null }; - return { - data: { - payload: { - results: [], - telemetry: { query_class: "unsupported_or_general", corpus_grounding: "in_corpus_topic" }, + createAdminClient: () => + withMockRagScopeRevision({ + from: (table: string) => { + const builder = { + select: () => builder, + eq: () => builder, + is: () => builder, + in: () => builder, + or: () => builder, + gt: () => builder, + order: () => builder, + limit: () => builder, + maybeSingle: async () => { + if (table !== "rag_response_cache") return { data: null, error: null }; + return { + data: { + payload: { + results: [], + telemetry: { query_class: "unsupported_or_general", corpus_grounding: "in_corpus_topic" }, + }, }, - }, - error: null, - }; - }, - then: (resolve: (value: { data: unknown[]; error: null }) => unknown) => - Promise.resolve({ - data: - table === "documents" ? [{ id: "doc-1", updated_at: "2026-07-14T00:00:00.000Z", metadata: {} }] : [], - error: null, - }).then(resolve), - }; - return builder; - }, - }), + error: null, + }; + }, + then: (resolve: (value: { data: unknown[]; error: null }) => unknown) => + Promise.resolve({ + data: + table === "documents" + ? [{ id: "doc-1", updated_at: "2026-07-14T00:00:00.000Z", metadata: {} }] + : [], + error: null, + }).then(resolve), + }; + return builder; + }, + }), })); const { getSharedCachedSearch } = await import("../src/lib/rag/rag-cache"); @@ -543,11 +561,14 @@ describe("shared RAG search cache", () => { })); vi.doMock("@/lib/deep-memory", () => ({ ragDeepMemoryVersion: "test-rag-version" })); vi.doMock("@/lib/clinical-search", () => ({ buildClinicalTextSearchQuery: (query: string) => query.trim() })); - vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => ({ from: () => builder }) })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: () => + withMockRagScopeRevision({ from: () => builder }, "rag-cache-scope-v1:" + "0".repeat(32)), + })); const { getSharedCachedSearch } = await import("../src/lib/rag/rag-cache"); const result = await getSharedCachedSearch({ query: "lithium monitoring", ownerId }, undefined, [], { - indexingVersionAtRequestStart: "index-v1", + indexingVersionAtRequestStart: "test-rag-version:rag-cache-scope-v1:" + "0".repeat(32), }); expect(result?.kind).toBe("hit"); @@ -603,12 +624,13 @@ describe("shared RAG search cache", () => { maybeSingle: async () => ({ data: null, error: null }), }; vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - from: (table: string) => { - if (table === "rag_response_cache") return shared.builder; - return docBuilder; - }, - }), + createAdminClient: () => + withMockRagScopeRevision({ + from: (table: string) => { + if (table === "rag_response_cache") return shared.builder; + return docBuilder; + }, + }), })); const { getSharedCachedAnswer } = await import("../src/lib/rag/rag-cache"); @@ -686,9 +708,10 @@ describe("shared RAG search cache", () => { buildClinicalTextSearchQuery: (query: string) => query.trim(), })); vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - from: (table: string) => (table === "documents" ? failingDocBuilder : cacheTableBuilder), - }), + createAdminClient: () => + withMockRagScopeRevision({ + from: (table: string) => (table === "documents" ? failingDocBuilder : cacheTableBuilder), + }), })); const { diff --git a/tests/rag-site-content-freshness.test.ts b/tests/rag-site-content-freshness.test.ts index 100426bea0..1d1b3e2c0c 100644 --- a/tests/rag-site-content-freshness.test.ts +++ b/tests/rag-site-content-freshness.test.ts @@ -1,3 +1,4 @@ +import { withMockRagScopeRevision, mockRagScopeRevision } from "./rag-cache-test-revision"; import { createHash } from "node:crypto"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -223,15 +224,16 @@ function installDeferredCacheHarness( vi.doMock("@/lib/deep-memory", () => ({ ragDeepMemoryVersion: "test-rag-version" })); vi.doMock("@/lib/clinical-search", () => ({ buildClinicalTextSearchQuery: (query: string) => query.trim() })); vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - from: (table: string) => { - if (table === "documents") { - documentReads += 1; - return documentBuilder; - } - return responseBuilder; - }, - }), + createAdminClient: () => + withMockRagScopeRevision({ + from: (table: string) => { + if (table === "documents") { + documentReads += 1; + return documentBuilder; + } + return responseBuilder; + }, + }), })); return { @@ -679,7 +681,7 @@ describe("RAG request site-content snapshot", () => { expect(legacy.ragRequestContext.snapshot.publicSiteContent.state).toBe("disabled"); expect(legacy.ragRequestContext.snapshotCacheKey).toBe(""); const answerKey = ragCacheModule.scopedAnswerCacheKey(legacy); - expect(answerKey).toMatch(/^rag-cache-v25\|[0-9a-f]{64}\|answer-owner:[0-9a-f]{64}\|answer-request:[0-9a-f]{64}$/); + expect(answerKey).toMatch(/^rag-cache-v26\|[0-9a-f]{64}\|answer-owner:[0-9a-f]{64}\|answer-request:[0-9a-f]{64}$/); for (const privateValue of ["owner-a", "clozapine monitoring", generation]) { expect(answerKey).not.toContain(privateValue); } @@ -912,7 +914,8 @@ describe("site-aware RAG cache isolation", () => { vi.doMock("@/lib/deep-memory", () => ({ ragDeepMemoryVersion: "test-rag-version" })); vi.doMock("@/lib/clinical-search", () => ({ buildClinicalTextSearchQuery: (query: string) => query.trim() })); vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ from: () => makeBuilder() }), + createAdminClient: () => + withMockRagScopeRevision({ from: () => makeBuilder() }, "rag-cache-scope-v1:" + "0".repeat(32)), })); const cache = await import("../src/lib/rag/rag-cache"); const base = snapshotModule.withRagRequestContext({ @@ -922,14 +925,14 @@ describe("site-aware RAG cache isolation", () => { }); await cache.getSharedCachedSearch({ ...base, ownerId: "owner-a" }, "table_threshold", [], { - indexingVersionAtRequestStart: "index-v1", + indexingVersionAtRequestStart: "test-rag-version:rag-cache-scope-v1:" + "0".repeat(32), }); expect(selectors).toContainEqual({ kind: "is", column: "owner_id", value: null }); expect(selectors).not.toContainEqual({ kind: "eq", column: "owner_id", value: "owner-a" }); selectors.length = 0; await cache.getSharedCachedAnswer({ ...base, ownerId: "owner-a" }, Date.now(), { - indexingVersionAtRequestStart: "index-v1", + indexingVersionAtRequestStart: "test-rag-version:rag-cache-scope-v1:" + "0".repeat(32), }); expect(selectors).toContainEqual({ kind: "eq", column: "owner_id", value: "owner-a" }); }); @@ -980,7 +983,9 @@ describe("site-aware RAG cache isolation", () => { vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => { adminClients += 1; - return { from: (table: string) => (table === "documents" ? documentBuilder : responseBuilder) }; + return withMockRagScopeRevision({ + from: (table: string) => (table === "documents" ? documentBuilder : responseBuilder), + }); }, })); const cache = await import("../src/lib/rag/rag-cache"); @@ -1163,7 +1168,7 @@ describe("site-aware RAG cache isolation", () => { await write; await vi.waitFor(() => expect(harness.inserts).toHaveLength(1)); const hit = await cache.getCachedSearch(originalArgs, "table_threshold", ["variant-original"], { - indexingVersionAtRequestStart: "test-rag-version:document-original:2026-08-29T00:00:00.000Z:", + indexingVersionAtRequestStart: `test-rag-version:${mockRagScopeRevision([{ id: "document-original", updated_at: "2026-08-29T00:00:00.000Z", metadata: {} }])}`, }); expect(hit?.results[0]).toMatchObject({ id: "chunk-canary-1", @@ -1176,8 +1181,8 @@ describe("site-aware RAG cache isolation", () => { cache_kind: "search", scope_key: "public-only|document-original", normalized_query: expectedSharedQuery, - indexing_version: "test-rag-version:document-original:2026-08-29T00:00:00.000Z:", - dependency_version: "rag-cache-v25", + indexing_version: `test-rag-version:${mockRagScopeRevision([{ id: "document-original", updated_at: "2026-08-29T00:00:00.000Z", metadata: {} }])}`, + dependency_version: "rag-cache-v26", payload: { results: [ expect.objectContaining({ @@ -1263,7 +1268,7 @@ describe("site-aware RAG cache isolation", () => { await write; await vi.waitFor(() => expect(harness.inserts).toHaveLength(1)); const hit = await cache.getCachedAnswer(originalArgs, Date.now(), { - indexingVersionAtRequestStart: "test-rag-version:document-original:2026-08-29T00:00:00.000Z:", + indexingVersionAtRequestStart: `test-rag-version:${mockRagScopeRevision([{ id: "document-original", updated_at: "2026-08-29T00:00:00.000Z", metadata: {} }])}`, }); expect(hit).toMatchObject({ answer: "Original answer text.", @@ -1461,7 +1466,7 @@ describe("site-aware RAG cache isolation", () => { expect(harness.inserts).toHaveLength(0); expect( await cache.getCachedSearch({ ...request, signal: undefined }, "table_threshold", [], { - indexingVersionAtRequestStart: "test-rag-version:document-original:2026-08-29T00:00:00.000Z:", + indexingVersionAtRequestStart: `test-rag-version:${mockRagScopeRevision([{ id: "document-original", updated_at: "2026-08-29T00:00:00.000Z", metadata: {} }])}`, }), ).toBeNull(); }); diff --git a/tests/rag-tail-latency.test.ts b/tests/rag-tail-latency.test.ts index 9c4a7097ff..82dac9f68f 100644 --- a/tests/rag-tail-latency.test.ts +++ b/tests/rag-tail-latency.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { mockRagScopeRevision } from "./rag-cache-test-revision"; import type { SearchTelemetry } from "../src/lib/rag/rag-contracts"; import type { RagAnswer } from "../src/lib/types"; @@ -6,7 +7,7 @@ const ownerId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const ragVersion = "test-rag-version"; function indexingVersion(stamp: string) { - return `${ragVersion}:doc-1:${stamp}:`; + return `${ragVersion}:${mockRagScopeRevision([{ id: "doc-1", metadata: { indexed_at: "unchanged-index", governance: stamp } }])}`; } function baseTelemetry(overrides: Partial = {}): SearchTelemetry { @@ -26,17 +27,37 @@ type CacheHarness = ReturnType; function createCacheHarness() { let documentStamp = "request-start"; - let documentReads = 0; + let revisionReads = 0; let clientCreations = 0; let advanceClockOnSecondClient = false; + let rows: Array> = []; const sharedReadVersions: string[] = []; const inserts: Array> = []; function chain(table: string) { + if (table !== "rag_response_cache") throw new Error(`Unexpected cache table: ${table}`); const filters = new Map(); + let operation = "select"; + let inserted: Record | undefined; + const matches = (row: Record) => + [...filters].every(([key, value]) => + key === "expires_at" + ? String(row[key]) > String(value) + : Array.isArray(value) + ? value.includes(row[key]) + : row[key] === value, + ); const builder = { select: () => builder, - delete: () => builder, + delete: () => { + operation = "delete"; + return builder; + }, + insert: (row: Record) => { + operation = "insert"; + inserted = structuredClone(row); + return builder; + }, eq: (column: string, value: unknown) => { filters.set(column, value); return builder; @@ -45,27 +66,28 @@ function createCacheHarness() { filters.set(column, value); return builder; }, - in: () => builder, - or: () => builder, - gt: () => builder, + in: (column: string, value: unknown) => { + filters.set(column, value); + return builder; + }, + gt: (column: string, value: unknown) => { + filters.set(column, value); + return builder; + }, order: () => builder, limit: () => builder, maybeSingle: async () => { sharedReadVersions.push(String(filters.get("indexing_version"))); - return { data: null, error: null }; + const row = rows.find(matches); + return { data: row ? { payload: structuredClone(row.payload) } : null, error: null }; }, - then: ( - resolve: (value: { data: unknown[] | null; error: null }) => unknown, - reject?: (reason: unknown) => unknown, - ) => { - if (table === "documents") { - documentReads += 1; - return Promise.resolve({ - data: [{ id: "doc-1", updated_at: documentStamp, metadata: {} }], - error: null, - }).then(resolve, reject); + then: (resolve: (value: { data: null; error: null }) => unknown, reject?: (reason: unknown) => unknown) => { + if (operation === "delete") rows = rows.filter((row) => !matches(row)); + if (operation === "insert") { + rows.push(inserted!); + inserts.push(inserted!); } - return Promise.resolve({ data: [], error: null }).then(resolve, reject); + return Promise.resolve({ data: null, error: null }).then(resolve, reject); }, }; return builder; @@ -73,24 +95,21 @@ function createCacheHarness() { const createAdminClient = vi.fn(() => { clientCreations += 1; - if (advanceClockOnSecondClient && clientCreations === 2) { - vi.setSystemTime(new Date(Date.now() + 6_000)); - } + if (advanceClockOnSecondClient && clientCreations === 2) vi.setSystemTime(new Date(Date.now() + 6_000)); return { - from: (table: string) => ({ - ...chain(table), - insert: async (row: Record) => { - inserts.push(row); - return { data: null, error: null }; - }, + rpc: vi.fn(async (name: string) => { + if (name !== "rag_cache_scope_revision_v1") throw new Error(`Unexpected cache RPC: ${name}`); + revisionReads += 1; + return { data: indexingVersion(documentStamp).slice(ragVersion.length + 1), error: null }; }), + from: chain, }; }); return { createAdminClient, - get documentReads() { - return documentReads; + get revisionReads() { + return revisionReads; }, get sharedReadVersions() { return sharedReadVersions; @@ -101,7 +120,11 @@ function createCacheHarness() { setDocumentStamp(stamp: string) { documentStamp = stamp; }, - expireVersionBeforeSharedWrite() { + resetReads() { + revisionReads = 0; + sharedReadVersions.length = 0; + }, + advanceClockBeforeSharedWrite() { advanceClockOnSecondClient = true; }, }; @@ -173,66 +196,90 @@ describe("governed corpus latency shape", () => { }); describe("RAG cache request indexing version", () => { - it("reuses one request-start version across shared answer and search cache reads", async () => { + it("brackets real shared hits with fresh revisions and refuses a stale request-start hint", async () => { const harness = createCacheHarness(); const cache = await loadCache(harness); const requestVersion = indexingVersion("request-start"); const args = { query: "lithium monitoring", ownerId }; - - await cache.getSharedCachedAnswer(args, Date.now(), { indexingVersionAtRequestStart: requestVersion }); - await cache.getSharedCachedSearch(args, "medication_dose_risk", [], { - indexingVersionAtRequestStart: requestVersion, + await cache.setCachedAnswer( + args, + { answer: "Current cached evidence", grounded: false, confidence: "unsupported", citations: [], sources: [] }, + { indexingVersionAtRetrievalStart: requestVersion }, + ); + await cache.setCachedSearch(args, [], baseTelemetry({ query_class: "medication_dose_risk" }), [], { + indexingVersionAtRetrievalStart: requestVersion, }); + await vi.waitFor(() => expect(harness.inserts).toHaveLength(2)); + harness.resetReads(); + + expect( + (await cache.getSharedCachedAnswer(args, Date.now(), { indexingVersionAtRequestStart: requestVersion }))?.answer, + ).toBe("Current cached evidence"); + expect( + await cache.getSharedCachedSearch(args, "medication_dose_risk", [], { + indexingVersionAtRequestStart: requestVersion, + }), + ).toMatchObject({ kind: "hit" }); + expect(harness.revisionReads).toBe(4); + expect(harness.sharedReadVersions).toEqual([requestVersion, requestVersion]); - expect(harness.documentReads).toBe(0); + // Governance changes while indexed_at remains fixed. A caller's old hint may not bypass the read fence. + harness.setDocumentStamp("reviewed-after-request-start"); + expect( + await cache.getSharedCachedAnswer(args, Date.now(), { indexingVersionAtRequestStart: requestVersion }), + ).toBeNull(); + expect( + await cache.getSharedCachedSearch(args, "medication_dose_risk", [], { + indexingVersionAtRequestStart: requestVersion, + }), + ).toMatchObject({ kind: "miss" }); + expect(harness.revisionReads).toBe(6); expect(harness.sharedReadVersions).toEqual([requestVersion, requestVersion]); }); - it("uses the request-start version for a process-local search cache read", async () => { + it("refreshes a local search hit and rejects it when governance changes after request start", async () => { const harness = createCacheHarness(); const cache = await loadCache(harness); const args = { query: "lithium monitoring", ownerId }; const requestVersion = indexingVersion("request-start"); - await cache.setCachedSearch(args, [], baseTelemetry({ query_class: "medication_dose_risk" }), [], { indexingVersionAtRetrievalStart: requestVersion, }); - harness.setDocumentStamp("changed-after-request-start"); - await cache.cacheIndexingVersion(args, { forceRefresh: true }); - - const cached = await cache.getCachedSearch(args, "medication_dose_risk", [], { - indexingVersionAtRequestStart: requestVersion, - }); - - expect(cached?.telemetry.search_cache_hit).toBe(true); + expect( + (await cache.getCachedSearch(args, "medication_dose_risk", [], { indexingVersionAtRequestStart: requestVersion })) + ?.telemetry.search_cache_hit, + ).toBe(true); + expect(harness.revisionReads).toBe(2); + harness.setDocumentStamp("reviewed-after-request-start"); + expect( + await cache.getCachedSearch(args, "medication_dose_risk", [], { indexingVersionAtRequestStart: requestVersion }), + ).toBeNull(); + expect(harness.revisionReads).toBe(3); }); - it("keeps one forced write refresh, rejects changed indexing, and passes the refresh to the shared writer", async () => { + it("keeps one authoritative write refresh, rejects changed governance, and persists that exact revision", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-07-14T00:00:00.000Z")); const harness = createCacheHarness(); harness.setDocumentStamp("changed"); const cache = await loadCache(harness); const args = { query: "lithium monitoring", ownerId }; - await cache.setCachedSearch(args, [], baseTelemetry({ query_class: "medication_dose_risk" }), [], { indexingVersionAtRetrievalStart: indexingVersion("request-start"), }); await Promise.resolve(); - - expect(harness.documentReads).toBe(1); + expect(harness.revisionReads).toBe(1); expect(harness.inserts).toHaveLength(0); vi.resetModules(); const matchingHarness = createCacheHarness(); - matchingHarness.expireVersionBeforeSharedWrite(); + matchingHarness.advanceClockBeforeSharedWrite(); const matchingCache = await loadCache(matchingHarness); await matchingCache.setCachedSearch(args, [], baseTelemetry({ query_class: "medication_dose_risk" }), [], { indexingVersionAtRetrievalStart: indexingVersion("request-start"), }); await vi.waitFor(() => expect(matchingHarness.inserts).toHaveLength(1)); - - expect(matchingHarness.documentReads).toBe(1); + expect(matchingHarness.revisionReads).toBe(1); expect(matchingHarness.inserts[0]?.indexing_version).toBe(indexingVersion("request-start")); }); }); @@ -509,7 +556,6 @@ function createOrchestrationHarness() { onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, ): PromiseLike { if (this.table === "documents") { - events.push({ kind: "index_version", value: documentStamp }); return Promise.resolve({ data: [{ id: "doc-1", updated_at: documentStamp, metadata: {} }], error: null, @@ -521,7 +567,17 @@ function createOrchestrationHarness() { return { createAdminClient: () => ({ - rpc: vi.fn(async () => ({ data: [], error: null })), + rpc: vi.fn((name: string) => { + let data: string | unknown[] = []; + if (name === "rag_cache_scope_revision_v1") { + events.push({ kind: "index_version", value: documentStamp }); + data = mockRagScopeRevision([ + { id: "doc-1", metadata: { indexed_at: "unchanged-index", governance: documentStamp } }, + ]); + } + const query = Promise.resolve({ data, error: null }); + return Object.assign(query, { abortSignal: () => query }); + }), from: (table: string) => Object.assign(new Query(table), { insert: async (row: Record) => { @@ -547,7 +603,7 @@ function createOrchestrationHarness() { } describe("answer-to-search cache request context", () => { - it("reuses one request-start indexing version across real answer and nested search cache reads", async () => { + it("shares request-start identity while refreshing every real answer and nested-search read fence", async () => { vi.doUnmock("@/lib/env"); vi.doUnmock("@/lib/deep-memory"); vi.doUnmock("@/lib/clinical-search"); @@ -567,14 +623,33 @@ describe("answer-to-search cache request context", () => { const rag = await import("../src/lib/rag/rag"); const cache = await import("../src/lib/rag/rag-cache"); - const args = { query: "coffee machine policy", ownerId, logQuery: false }; + const { withRagProgrammeRollout } = await import("../src/lib/rag/rag-rollout"); + const { analyzeClinicalQuery } = await import("../src/lib/clinical-search"); + const { buildRetrievalQueryVariants } = await import("../src/lib/rag/rag-retrieval-variants"); + const args = withRagProgrammeRollout({ query: "coffee machine policy", ownerId, logQuery: false }); // Prime stale process-local answer and search entries. The orchestration request below - // must reject both local entries, continue through both shared reads, and carry the one - // request-start version into the nested search instead of resolving it again. - await rag.searchChunksWithTelemetry(args); - await cache.setCachedAnswer(args, {} as RagAnswer); - await vi.waitFor(() => expect(harness.events.some((event) => event.kind === "shared_write")).toBe(true)); + // must reject both local entries and continue through both shared reads. Request-start + // identity is shared, while each hit/lookup and in-flight admission reads its own fresh fence. + const searchArgs = { ...args, topK: 12, minSimilarity: 0.12 }; + const prime = await rag.searchChunksWithTelemetry(searchArgs); + // Empty source-only retrieval does not populate the search cache. Seed its exact + // production identity explicitly and prove the actual consumer can hit it first. + const variants = buildRetrievalQueryVariants(args.query, analyzeClinicalQuery(args.query)); + await cache.setCachedSearch(searchArgs, prime.results, prime.telemetry, variants); + const primed = await rag.searchChunksWithTelemetry(searchArgs); + expect(primed.telemetry.search_cache_hit).toBe(true); + await cache.setCachedAnswer(args, { + answer: "Stale fixture", + grounded: false, + confidence: "unsupported", + citations: [], + sources: [], + } satisfies RagAnswer); + expect((await cache.getCachedAnswer(args, Date.now()))?.answer).toBe("Stale fixture"); + await vi.waitFor(() => + expect(harness.events.some((event) => event.kind === "shared_write" && event.cacheKind === "search")).toBe(true), + ); harness.setDocumentStamp("request-start"); harness.clearEvents(); @@ -600,7 +675,10 @@ describe("answer-to-search cache request context", () => { expect(answer.routingReason).not.toContain("cache_hit"); expect(sharedReads.map((event) => event.indexingVersion)).toEqual([requestVersion, requestVersion]); - expect(requestVersion).toMatch(/:doc-1:request-start:$/); - expect(versionsResolvedBeforeNestedSearch).toEqual([{ kind: "index_version", value: "request-start" }]); + expect(requestVersion).toMatch(/:rag-cache-scope-v1:[0-9a-f]{32}$/); + // In-flight admission, request start, local-answer, shared-answer, local-search, shared-search. + expect(versionsResolvedBeforeNestedSearch).toEqual( + Array.from({ length: 6 }, () => ({ kind: "index_version", value: "request-start" })), + ); }); }); diff --git a/tests/railway-config.test.ts b/tests/railway-config.test.ts index 04eb5766bb..094402736a 100644 --- a/tests/railway-config.test.ts +++ b/tests/railway-config.test.ts @@ -178,6 +178,8 @@ describe("Railway config as code", () => { "worker/python/requirements.txt", // B4: the worker image builds its docling venv from the Gate B lab lock. "eval/docling/requirements.txt", + "eval/docling/model-artifacts.json", + "eval/docling/verify-model-artifacts.mjs", "scripts/build-worker.mjs", "scripts/enable-server-only-stub.mjs", "scripts/register-server-only.mjs", diff --git a/tests/retrieval-access-scope.test.ts b/tests/retrieval-access-scope.test.ts index b28f5a2bb8..91c4d84ee2 100644 --- a/tests/retrieval-access-scope.test.ts +++ b/tests/retrieval-access-scope.test.ts @@ -1,7 +1,8 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mockRagScopeRevision } from "./rag-cache-test-revision"; const root = process.cwd(); const optimizedRetrievalMigration = readFileSync( @@ -26,6 +27,11 @@ function between(source: string, start: string, end: string): string { return source.slice(startIndex, endIndex); } +afterEach(() => { + vi.doUnmock("@/lib/supabase/admin"); + vi.resetModules(); +}); + describe("owner-plus-public retrieval contract", () => { it("adds a separate public-only v3 candidate contract without changing v2", () => { expect(governedV3Migration).toContain("match_document_chunks_text_v3"); @@ -41,7 +47,81 @@ describe("owner-plus-public retrieval contract", () => { const cache = readFileSync(join(root, "src/lib/rag/rag-cache.ts"), "utf8"); expect(contracts).toContain("accessScope?: RetrievalAccessScope"); expect(cache).toContain("retrievalAccessScopeKey(retrievalAccessScopeForArgs(args))"); - expect(cache).toContain("owner_id.is.null"); + expect(cache).toContain('rpc("rag_cache_scope_revision_v1"'); + expect(cache).toContain("p_owner_id: accessScope.ownerId ?? null"); + expect(cache).toContain("p_include_public: accessScope.includePublic"); + const revisionSql = between( + canonicalSchema, + "create function public.rag_cache_scope_revision_v1(", + "-- HY05:", + ).replace(/\s+/g, " "); + expect(revisionSql).toContain("d.owner_id = p_owner_id"); + expect(revisionSql).toContain("coalesce(p_include_public,false) and d.owner_id is null"); + expect(revisionSql).toContain("d.id = any(p_document_ids)"); + expect(revisionSql).toMatch( + /revoke all on function public\.rag_cache_scope_revision_v1\([^]*from public, anon, authenticated;/, + ); + expect(revisionSql).toMatch(/grant execute on function public\.rag_cache_scope_revision_v1\([^]*to service_role;/); + }); + + it("binds fresh revision reads to exact owner/public and document scopes without foreign-owner fallback", async () => { + const documents = [ + { id: "owned", owner_id: "owner-a", metadata: { indexed_at: "unchanged", document_status: "current" } }, + { id: "public", owner_id: null, metadata: { indexed_at: "unchanged", document_status: "current" } }, + { id: "foreign", owner_id: "owner-b", metadata: { indexed_at: "unchanged", document_status: "current" } }, + ]; + const rpc = vi.fn( + async ( + name: string, + params: { p_owner_id: string | null; p_include_public: boolean; p_document_ids: string[] | null }, + ) => { + expect(name).toBe("rag_cache_scope_revision_v1"); + const scoped = documents.filter( + (row) => + ((params.p_owner_id !== null && row.owner_id === params.p_owner_id) || + (row.owner_id === null && params.p_include_public)) && + (!params.p_document_ids?.length || params.p_document_ids.includes(row.id)), + ); + return { data: mockRagScopeRevision(scoped), error: null }; + }, + ); + vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => ({ rpc }) })); + const { cacheIndexingVersion } = await import("../src/lib/rag/rag-cache"); + const version = async ( + accessScope: { ownerId?: string; includePublic: boolean }, + filters: { documentId?: string; documentIds?: string[] } = {}, + ) => cacheIndexingVersion({ ownerId: accessScope.ownerId, accessScope, ...filters }); + const publicOnly = await version({ includePublic: true }); + expect(publicOnly).toContain(mockRagScopeRevision([documents[1]])); + const ownOnly = await version({ ownerId: "owner-a", includePublic: false }); + expect(ownOnly).toContain(mockRagScopeRevision([documents[0]])); + const combined = await version( + { ownerId: "owner-a", includePublic: true }, + { documentIds: ["public", "owned", "public"] }, + ); + expect(combined).toContain(mockRagScopeRevision(documents.slice(0, 2))); + expect(rpc).toHaveBeenLastCalledWith("rag_cache_scope_revision_v1", { + p_owner_id: "owner-a", + p_include_public: true, + p_document_ids: ["owned", "public"], + }); + expect(await version({ ownerId: "owner-a", includePublic: true }, { documentId: "foreign" })).toContain( + mockRagScopeRevision([]), + ); + expect(rpc).toHaveBeenLastCalledWith("rag_cache_scope_revision_v1", { + p_owner_id: "owner-a", + p_include_public: true, + p_document_ids: ["foreign"], + }); + expect(await version({ includePublic: false })).toContain(mockRagScopeRevision([])); + documents[1]!.metadata.document_status = "outdated"; + expect(await version({ includePublic: true })).not.toBe(publicOnly); + expect(await version({ ownerId: "owner-a", includePublic: false })).toBe(ownOnly); + const beforeMismatch = rpc.mock.calls.length; + expect( + await cacheIndexingVersion({ ownerId: "owner-a", accessScope: { ownerId: "owner-b", includePublic: true } }), + ).toContain("index-stamp-unavailable"); + expect(rpc).toHaveBeenCalledTimes(beforeMismatch); }); it("keeps answer cache namespaces distinct across public and owner-plus-public scopes", async () => { diff --git a/tests/roster-auth-transition.dom.test.tsx b/tests/roster-auth-transition.dom.test.tsx new file mode 100644 index 0000000000..f6088a5ec0 --- /dev/null +++ b/tests/roster-auth-transition.dom.test.tsx @@ -0,0 +1,257 @@ +/** @vitest-environment jsdom */ +// Mounted existing-B/examples-off regression. Every request is intercepted; no backend IDOR claim. +import { act, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + auth: { + status: "authenticated", + authEpoch: 1, + session: { user: { id: "synthetic-A", created_at: "2020-01-01T00:00:00Z" } }, + }, + browserActor: "synthetic-A", +})); +vi.mock("@/lib/supabase/client", () => ({ + useAuthSession: () => state.auth, + hasSupabaseAuthSession: () => state.auth.status === "authenticated", +})); +// Explicit example-data off: supported production setting, keeps RosterSampleGate's real child mounted. +vi.mock("@/lib/example-data/store", () => ({ + useExampleData: () => ({ active: false, mode: "off" }), + useAuthIfAvailable: () => state.auth, + areaDataState: () => "has-data", + reportAreaData: () => undefined, +})); +vi.mock("next/navigation", () => ({ + usePathname: () => "/roster/settings", + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), prefetch: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})); +import { useRosterSettings } from "@/components/roster/use-roster-settings"; +import { useRosterShifts } from "@/components/roster/use-roster-shifts"; +import { RosterSettingsPage } from "@/components/roster/roster-settings-page"; +import { RosterSampleGate } from "@/components/roster/roster-sample-gate"; +import { resetSharedGets } from "@/lib/shared-get"; +import { ACCOUNT_TRANSITION_EVENT } from "@/lib/account-scoped-browser-state"; + +function changeToB() { + // Replace the entire published value, as AuthProvider does; never mutate an A closure. + state.auth = { + status: "authenticated", + authEpoch: state.auth.authEpoch + 1, + session: { user: { id: "synthetic-B", created_at: "2020-01-01T00:00:00Z" } }, + }; + state.browserActor = "synthetic-B"; + resetSharedGets(); + window.dispatchEvent(new Event(ACCOUNT_TRANSITION_EVENT)); +} +const syntheticShift = { + id: "synthetic-shift-A", + startsAt: "2026-10-20T00:00:00Z", + endsAt: "2026-10-20T08:00:00Z", + title: "Synthetic A shift", + location: null, + sourceUid: null, + kind: "day", + source: "import", + seriesId: null, + workplace: "Synthetic A workplace", +}; +let calls: { actorAtRequest: string; url: string; method: string }[]; +beforeEach(() => { + state.auth = { + status: "authenticated", + authEpoch: 1, + session: { user: { id: "synthetic-A", created_at: "2020-01-01T00:00:00Z" } }, + }; + state.browserActor = "synthetic-A"; + resetSharedGets(); + calls = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input), + method = init?.method ?? "GET"; + // Models browser ambient-cookie selection at REQUEST time, not a backend authorization bypass. + calls.push({ actorAtRequest: state.browserActor, url, method }); + if (url === "/api/roster/settings") + return Response.json({ + settings: { + calendarShifts: false, + rowName: state.browserActor === "synthetic-A" ? "Synthetic A row" : "Synthetic B row", + codes: {}, + }, + }); + if (url === "/api/roster/shifts") + return Response.json({ + shifts: method === "GET" && state.browserActor === "synthetic-A" ? [syntheticShift] : [], + latestImport: null, + }); + if (url === "/api/roster/team") return Response.json({ teams: [], actorId: state.browserActor }); + if (url === "/api/roster/links") return Response.json({ links: [] }); + return Response.json({}); + }), + ); +}); +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + resetSharedGets(); +}); +describe("audit: Roster respects a mounted account transition", () => { + it("discards A settings and reads B settings after transition", async () => { + const view = renderHook(() => useRosterSettings()); + await waitFor(() => expect(view.result.current.settings.rowName).toBe("Synthetic A row")); + act(() => { + changeToB(); + view.rerender(); + }); + await waitFor(() => expect(view.result.current.settings.rowName).toBe("Synthetic B row")); + }); + it("discards A own shifts after transition", async () => { + const view = renderHook(() => useRosterShifts()); + await waitFor(() => expect(view.result.current.status).toBe("ready")); + expect(view.result.current.shifts[0]?.id).toBe("synthetic-shift-A"); + act(() => { + changeToB(); + view.rerender(); + }); + await waitFor(() => expect(view.result.current.shifts).toHaveLength(0)); + }); + it("cancels A pending delete rather than issuing it under B ambient identity", async () => { + const tree = () => ( + + + + ); + const view = render(tree()); + await screen.findByText("Synthetic A workplace"); + vi.useFakeTimers(); + fireEvent.click(screen.getByRole("button", { name: /Delete my roster data/ })); + expect(screen.getByRole("button", { name: "Undo" })).toBeInTheDocument(); + act(() => { + changeToB(); + view.rerender(tree()); + }); + await act(async () => { + vi.advanceTimersByTime(31_000); + }); + expect(calls.filter((call) => call.url === "/api/roster/shifts" && call.method === "DELETE")).toEqual([]); + }); +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +describe("Roster identity races and same-account controls", () => { + it("drops a late settings GET body after B has loaded", async () => { + const original = globalThis.fetch; + const body = deferred(); + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) === "/api/roster/settings" && state.browserActor === "synthetic-A") + return Promise.resolve({ ok: true, status: 200, json: () => body.promise }); + return original(input, init); + }), + ); + const view = renderHook(useRosterSettings); + await act(async () => {}); + act(() => { + changeToB(); + view.rerender(); + }); + await waitFor(() => expect(view.result.current.settings.rowName).toBe("Synthetic B row")); + await act(async () => body.resolve({ rowName: "Late A row" })); + expect(view.result.current.settings.rowName).toBe("Synthetic B row"); + }); + + for (const status of [200, 500]) { + it(`drops A save completion/rollback (${status}) and A queued settings write`, async () => { + const original = globalThis.fetch; + const pending = deferred(); + const puts: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) === "/api/roster/settings" && init?.method === "PUT") { + puts.push(state.browserActor); + return puts.length === 1 ? pending.promise : Promise.resolve(Response.json({ rowName: "B saved" })); + } + return original(input, init); + }), + ); + const view = renderHook(useRosterSettings); + await waitFor(() => expect(view.result.current.status).toBe("ready")); + let old!: Promise[]; + act(() => { + old = [view.result.current.update({ rowName: "A first" }), view.result.current.update({ rowName: "A second" })]; + }); + await waitFor(() => expect(puts).toHaveLength(1)); + act(() => { + changeToB(); + view.rerender(); + }); + await waitFor(() => expect(view.result.current.settings.rowName).toBe("Synthetic B row")); + await act(async () => { + await view.result.current.update({ rowName: "B saved" }); + }); + await act(async () => { + pending.resolve(Response.json({ rowName: "A first" }, { status })); + await Promise.all(old); + }); + expect(puts).toEqual(["synthetic-A", "synthetic-B"]); + expect(view.result.current.settings.rowName).toBe("B saved"); + }); + } + + it("drops a late own-shifts save and refuses a retained A destructive callback under B", async () => { + const original = globalThis.fetch; + const pending = deferred(); + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL, init?: RequestInit) => + String(input) === "/api/roster/shifts" && init?.method === "POST" ? pending.promise : original(input, init), + ), + ); + const view = renderHook(useRosterShifts); + await waitFor(() => expect(view.result.current.status).toBe("ready")); + const old = view.result.current; + let save!: Promise; + act(() => { + save = old.save({} as Parameters[0]); + }); + act(() => { + changeToB(); + view.rerender(); + }); + await waitFor(() => expect(view.result.current.status).toBe("ready")); + await act(async () => { + pending.resolve(Response.json({ shifts: [syntheticShift] })); + await save; + await old.deleteAll(); + }); + expect(view.result.current.shifts).toEqual([]); + expect(calls.filter((call) => call.method === "DELETE")).toEqual([]); + }); + + it("keeps same-account Undo effective", async () => { + render( + + + , + ); + await screen.findByText("Synthetic A workplace"); + vi.useFakeTimers(); + fireEvent.click(screen.getByRole("button", { name: /Delete my roster data/ })); + fireEvent.click(screen.getByRole("button", { name: "Undo" })); + await act(async () => { + vi.advanceTimersByTime(31_000); + }); + expect(calls.filter((call) => call.method === "DELETE")).toEqual([]); + }); +}); diff --git a/tests/roster-cover-tab.dom.test.tsx b/tests/roster-cover-tab.dom.test.tsx index fdbb8af749..4d1d380507 100644 --- a/tests/roster-cover-tab.dom.test.tsx +++ b/tests/roster-cover-tab.dom.test.tsx @@ -1,13 +1,71 @@ /** @vitest-environment jsdom */ -import { cleanup, render, screen, within } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { afterEach, expect, it, vi } from "vitest"; -import { RosterCoverTab } from "@/components/roster/manage/roster-cover-tab"; +import { RosterCoverGap, RosterCoverTab } from "@/components/roster/manage/roster-cover-tab"; import type { RosterOverview } from "@/lib/roster/team/model"; +const work = vi.hoisted(() => ({ zone: "Australia/Perth" })); +vi.mock("@/components/work-time/use-work-time-zone", () => ({ useWorkTimeZone: () => ({ zone: work.zone }) })); afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); + work.zone = "Australia/Perth"; +}); + +it("posts an overnight Cover gap using the selected Sydney wall clock", async () => { + work.zone = "Australia/Sydney"; + const fetcher = vi.fn(async () => Response.json({ result: { openShiftId: "synthetic" } })); + vi.stubGlobal("fetch", fetcher); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: /Gap:/ })); + fireEvent.change(screen.getByLabelText("Starts (Sydney and Canberra)"), { target: { value: "23:00" } }); + fireEvent.change(screen.getByLabelText("Ends (Sydney and Canberra)"), { target: { value: "07:00" } }); + fireEvent.change(screen.getByLabelText("Shift code"), { target: { value: "N" } }); + fireEvent.click(screen.getByRole("button", { name: "Post gap" })); + await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); + const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string); + expect(body).toMatchObject({ startsAt: "2026-10-17T12:00:00.000Z", endsAt: "2026-10-17T20:00:00.000Z" }); +}); + +it("blocks a Cover gap at a skipped daylight-saving time", () => { + work.zone = "Australia/Sydney"; + const fetcher = vi.fn(); + vi.stubGlobal("fetch", fetcher); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: /Gap:/ })); + fireEvent.change(screen.getByLabelText("Starts (Sydney and Canberra)"), { target: { value: "02:30" } }); + fireEvent.change(screen.getByLabelText("Ends (Sydney and Canberra)"), { target: { value: "08:00" } }); + fireEvent.change(screen.getByLabelText("Shift code"), { target: { value: "D" } }); + expect(screen.getByRole("alert")).toHaveTextContent("Choose valid times"); + expect(screen.getByRole("button", { name: "Post gap" })).toBeDisabled(); + expect(fetcher).not.toHaveBeenCalled(); }); const overview: RosterOverview = { diff --git a/tests/roster-month-page.dom.test.tsx b/tests/roster-month-page.dom.test.tsx index 2b0a7147e3..2da7443651 100644 --- a/tests/roster-month-page.dom.test.tsx +++ b/tests/roster-month-page.dom.test.tsx @@ -20,6 +20,8 @@ const state = vi.hoisted(() => ({ reads: {} as Record, readCalls: [] as [string | null, string][], entries: [] as unknown[], + sources: {} as Record, + retryCalendar: vi.fn(), })); vi.mock("next/navigation", () => ({ @@ -41,7 +43,7 @@ vi.mock("@/components/roster/use-roster-team", () => ({ // The Open shifts row reads every team's list; its own tests cover it. vi.mock("@/components/open-shifts/open-shifts-fit-line", () => ({ OpenShiftsFitLine: () => null })); vi.mock("@/components/work-calendar/use-work-calendar-entries", () => ({ - useWorkCalendarEntries: () => ({ entries: state.entries }), + useWorkCalendarEntries: () => ({ entries: state.entries, sources: state.sources, retry: state.retryCalendar }), })); vi.mock("@/components/roster/use-roster-links", () => ({ useRosterLinks: () => ({ links: [], add: vi.fn() }) })); vi.mock("@/components/roster/use-roster-settings", () => ({ useRosterSettings: () => ({}) })); @@ -91,6 +93,8 @@ function ready(shifts: unknown[], extra: Record = {}) { beforeEach(() => { state.entries = []; + state.sources = {}; + state.retryCalendar.mockClear(); state.readCalls = []; state.reads = {}; state.teams = { @@ -112,6 +116,31 @@ beforeEach(() => { afterEach(cleanup); describe("Roster Month tab", () => { + it.each([ + ["rotations", "Rotations"], + ["bookings", "Course bookings"], + ])("names failed %s, keeps shifts visible and retries only that source", (id, label) => { + state.sources = { [id]: { status: "error", entries: [] } }; + state.entries = [ + { id: "successful", kind: "course", title: "Loaded course", start: "2026-10-07", end: "2026-10-07" }, + ]; + const { rerender } = render(); + const warning = screen.getByTestId(`roster-month-source-${id}`); + expect(warning).toHaveTextContent(`${label} could not be loaded`); + expect(screen.getByTestId("roster-month-lead")).toHaveTextContent("On call tonight"); + expect(screen.getByTestId("roster-month-coming")).toHaveTextContent("Loaded course"); + fireEvent.click(within(warning).getByRole("button", { name: `Retry ${label.toLowerCase()}` })); + expect(state.retryCalendar).toHaveBeenCalledWith(id); + state.sources = { [id]: { status: "ready", entries: [] } }; + state.entries = [ + ...state.entries, + { id: "recovered", kind: "rotation", title: "Recovered rotation", start: "2026-10-09", end: "2026-10-10" }, + ]; + rerender(); + expect(screen.queryByTestId(`roster-month-source-${id}`)).toBeNull(); + expect(screen.getByRole("button", { name: /Fri 9 Oct:.*Recovered rotation/ })).toBeInTheDocument(); + }); + it("draws the month with a spoken shift code on every day and marks today", () => { render(); const grid = screen.getByRole("grid", { name: "Your shifts, October 2026" }); diff --git a/tests/test-runner-safety.test.ts b/tests/test-runner-safety.test.ts index 013d87c929..6d1c0d9da7 100644 --- a/tests/test-runner-safety.test.ts +++ b/tests/test-runner-safety.test.ts @@ -183,6 +183,19 @@ describe("temporary path cleanup", () => { }); describe("repository-wide heavyweight lock", () => { + it.each([ + ["State:\tZ (zombie)\nThreads:\t1\n", true], + ["State:\tX (dead)\nThreads:\t1\n", true], + ["State:\tZ (zombie)\nThreads:\t2\n", false], + ["State:\tZ (zombie)\n", false], + ["State:\tZ (zombie)\nThreads:\tunknown\n", false], + ["State:\tZ (zombie)\nThreads:\t1 extra\n", false], + ["State:\tZ (zombie)\nThreads:\t0\n", false], + ["State:\tS (sleeping)\nThreads:\t1\n", false], + ])("reclaims only an explicitly single-thread defunct Linux status %j", (status, expected) => { + expect(testRunLockInternals.linuxProcessIsDefunct(status)).toBe(expected); + }); + it("lets exclusive work queue through long browser or build leases without making focused runs wait indefinitely", () => { expect(testRunLockInternals.defaultWaitTimeoutFor("shared")).toBe(30_000); expect(testRunLockInternals.defaultWaitTimeoutFor("exclusive")).toBe(15 * 60_000); @@ -676,6 +689,168 @@ describe("repository-wide heavyweight lock", () => { replacement.release(); }); + it.runIf(process.platform === "linux")( + "reclaims an actual zombie owner's lease while preserving a live owner", + async () => { + const baseDirectory = temporaryDirectory("clinical-kb-zombie-lock-"); + const repositoryIdentity = path.join(baseDirectory, "shared.git"); + // Node reaps its own children automatically. A tiny Python parent keeps its + // exited fork unreaped until stdin closes, then always waitpids it in finally. + const fixture = spawn( + process.env.PYTHON_BIN || "python3", + [ + "-u", + "-c", + ` +import os, sys +reader, writer = os.pipe() +pid = os.fork() +if pid == 0: + os.close(writer) + os.read(reader, 1) + os._exit(0) +os.close(reader) +print(pid, flush=True) +try: + sys.stdin.readline() + os.write(writer, b"x") + os.close(writer) + sys.stdin.readline() +finally: + os.waitpid(pid, 0) +`, + ], + { stdio: ["pipe", "pipe", "pipe"] }, + ); + let stdout = ""; + let stderr = ""; + let launchError: Error | undefined; + fixture.stdout.on("data", (chunk) => (stdout += String(chunk))); + fixture.stderr.on("data", (chunk) => (stderr += String(chunk))); + fixture.on("error", (error) => { + launchError = error; + }); + const finished = new Promise((resolve) => fixture.on("close", resolve)); + let first: ReturnType | undefined; + let replacement: ReturnType | undefined; + try { + await waitForCondition(() => { + if (launchError) throw launchError; + return /^\d+\n/.test(stdout); + }); + const pid = Number(stdout.trim()); + expect(testRunLockInternals.processIsAlive(pid)).toBe(true); + const acquire = (processId: number, worktree: string) => + acquireHeavyRunLock({ + projectRoot: path.join(baseDirectory, worktree), + repositoryIdentity, + baseDirectory, + environment: {}, + processId, + command: "zombie liveness fixture", + waitTimeoutMs: 0, + }); + first = acquire(pid, "worktree-a"); + expect(() => acquire(process.pid, "worktree-b")).toThrow(/heavyweight command is active/); + + fixture.stdin.write("exit-child\n"); + await waitForCondition(() => /^State:\s+Z\b/m.test(readFileSync(`/proc/${pid}/status`, "utf8"))); + expect(() => process.kill(pid, 0)).not.toThrow(); + // The public API must prune the defunct lease immediately, without force + // or an aged heartbeat; kill(pid, 0) alone incorrectly blocks this call. + replacement = acquire(process.pid, "worktree-b"); + expect(testRunLockInternals.processIsAlive(pid)).toBe(false); + expect(testRunLockInternals.processIsAlive(process.pid)).toBe(true); + first.release(); + expect(readFileSync(path.join(requireLeasePath(replacement), "owner.json"), "utf8")).toContain( + replacement.owner.token, + ); + } finally { + replacement?.release(); + first?.release(); + fixture.stdin.end("finish\nfinish\n"); + expect(await finished, stderr).toBe(0); + } + }, + ); + + it.runIf(process.platform === "linux")( + "preserves a lease when its zombie leader still has a live worker thread", + async () => { + const baseDirectory = temporaryDirectory("clinical-kb-threaded-zombie-lock-"); + const repositoryIdentity = path.join(baseDirectory, "shared.git"); + const fixture = spawn( + process.env.PYTHON_BIN || "python3", + [ + "-u", + "-c", + ` +import ctypes, os, sys, threading +def worker(): + while sys.stdin.readline(): + print("worker-alive", flush=True) +print("ready", flush=True) +sys.stdin.readline() +threading.Thread(target=worker).start() +ctypes.CDLL(None).pthread_exit(None) +`, + ], + { stdio: ["pipe", "pipe", "pipe"] }, + ); + let stdout = ""; + let stderr = ""; + let launchError: Error | undefined; + fixture.stdout.on("data", (chunk) => (stdout += String(chunk))); + fixture.stderr.on("data", (chunk) => (stderr += String(chunk))); + fixture.on("error", (error) => { + launchError = error; + }); + const finished = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => + fixture.on("close", (code, signal) => resolve({ code, signal })), + ); + let first: ReturnType | undefined; + let replacement: ReturnType | undefined; + try { + await waitForCondition(() => { + if (launchError) throw launchError; + return stdout.includes("ready"); + }); + const pid = fixture.pid!; + const acquire = (processId: number, worktree: string) => + acquireHeavyRunLock({ + projectRoot: path.join(baseDirectory, worktree), + repositoryIdentity, + baseDirectory, + environment: {}, + processId, + command: "live threaded leader fixture", + waitTimeoutMs: 0, + }); + first = acquire(pid, "worktree-a"); + fixture.stdin.write("exit-leader\n"); + await waitForCondition(() => { + const status = readFileSync(`/proc/${pid}/status`, "utf8"); + return /^State:\s+Z\b/m.test(status) && /^Threads:\s+2\s*$/m.test(status); + }); + fixture.stdin.write("ping-worker\n"); + await waitForCondition(() => stdout.includes("worker-alive")); + expect(() => process.kill(pid, 0)).not.toThrow(); + // Even though the leader exited, the heartbeat's owner process can still + // execute work. A competing lease must not prune its current token. + expect(() => { + replacement = acquire(process.pid, "worktree-b"); + }).toThrow(/heavyweight command is active/); + expect(testRunLockInternals.processIsAlive(pid)).toBe(true); + expect(readFileSync(path.join(requireLeasePath(first), "owner.json"), "utf8")).toContain(first.owner.token); + } finally { + replacement?.release(); + first?.release(); + fixture.kill("SIGTERM"); + expect(await finished, stderr).toMatchObject({ code: null, signal: "SIGTERM" }); + } + }, + ); + it("allows an explicit force-lock-release to replace a live owner", () => { const baseDirectory = temporaryDirectory("clinical-kb-force-lock-"); const repositoryIdentity = path.join(baseDirectory, "shared.git"); diff --git a/tests/trusted-document-admission.test.ts b/tests/trusted-document-admission.test.ts index d303542918..e2dbc511a7 100644 --- a/tests/trusted-document-admission.test.ts +++ b/tests/trusted-document-admission.test.ts @@ -564,7 +564,7 @@ describe("superseded collision object release", () => { } }); - it("reports an unrecorded object, with both failure reasons, when removal and the ledger fail", async () => { + it("reports an unrecorded object, with both failure stages and safe codes, when removal and the ledger fail", async () => { await expect( release( vi.fn(async () => failed), @@ -572,14 +572,24 @@ describe("superseded collision object release", () => { ), ).resolves.toEqual({ outcome: "unrecorded", - failures: ["remove: storage unavailable", "queue: storage unavailable"], + failures: [ + { stage: "remove", code: "unknown" }, + { stage: "queue", code: "unknown" }, + ], }); - await expect( - release( - vi.fn(async () => failed), - vi.fn(async () => Promise.reject(new Error("db"))), - ), - ).resolves.toEqual({ outcome: "unrecorded", failures: ["remove: storage unavailable", "queue: db"] }); + const rejected = await release( + vi.fn(async () => failed), + vi.fn(async () => Promise.reject(new Error("db"))), + ); + expect(rejected).toEqual({ + outcome: "unrecorded", + failures: [ + { stage: "remove", code: "unknown" }, + { stage: "queue", code: "provider_error" }, + ], + }); + // Only a stage and a safe code are reported; raw provider messages never reach the logs. + expect(JSON.stringify(rejected)).not.toMatch(/storage unavailable|db/); }); it("never touches the path the committed document still uses", async () => { diff --git a/tests/ui-tools-search-mode-mockup.spec.ts b/tests/ui-tools-search-mode-mockup.spec.ts index df61cd58b1..323506ec62 100644 --- a/tests/ui-tools-search-mode-mockup.spec.ts +++ b/tests/ui-tools-search-mode-mockup.spec.ts @@ -114,6 +114,8 @@ test.describe("Perfected Tools results mode mockup @mockup", () => { // A hard-coded total silently rots the moment the catalogue gains a tool, which // is what "Add Ward Flow" (#2140, since retired) did to the 14 this line used to carry. const results = mockup.locator('section[aria-label="Tool results"] li'); + // Shell visibility and input state can precede the results after hydration. + await expect(results.first()).toBeVisible(); const rendered = await results.count(); expect(rendered, "the Compare mockup must render at least one tool").toBeGreaterThan(0); await expect(mockup.getByText(`${rendered} ${rendered === 1 ? "tool" : "tools"}`, { exact: true })).toBeVisible(); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 1132988d57..84add76548 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -3603,3 +3603,35 @@ test.describe("Responsive layout guards", () => { expect(appRequests).toEqual([]); }); }); + +test.describe("physiological calculator validation hold", () => { + for (const viewport of [ + { name: "phone", width: 390, height: 844 }, + { name: "desktop", width: 1440, height: 900 }, + ]) { + test(`withholds unvalidated physiological outputs and preserves psychometric tools on ${viewport.name}`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + await blockExternalRequests(page); + await gotoLauncher(page, "/calculators/search"); + + // The whole panel stays off the page until clinician verification, even + // though the retained component independently enforces its release hold. + await expect(page.getByTestId("clinical-math-panel")).toHaveCount(0); + await expect(page.getByTestId("clinical-math-panel-toggle")).toHaveCount(0); + await expect(page.getByTestId("clinical-math-release-hold")).toHaveCount(0); + await expect(page.getByTestId("clinical-math-renal-results")).toHaveCount(0); + await expect(page.getByTestId("clinical-math-qtc-results")).toHaveCount(0); + await expect(page.getByTestId("clinical-math-lithium-level")).toHaveCount(0); + await expect(page.getByTestId("clinical-math-lithium-results")).toHaveCount(0); + await expect(page.getByTestId("clinical-math-lithium-suggested-dose")).toHaveCount(0); + + const psychometricList = page.getByTestId("calculator-result-list"); + await psychometricList.scrollIntoViewIfNeeded(); + await expect(psychometricList.getByRole("button", { name: /^Open PHQ-9/ })).toBeVisible(); + await expect(psychometricList.getByRole("button")).toHaveCount(5); + await expectNoPageHorizontalOverflow(page); + }); + } +}); diff --git a/tests/upload-diagnostic-minimisation.test.ts b/tests/upload-diagnostic-minimisation.test.ts new file mode 100644 index 0000000000..ead5beccd4 --- /dev/null +++ b/tests/upload-diagnostic-minimisation.test.ts @@ -0,0 +1,198 @@ +import { createHash } from "node:crypto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + admin: vi.fn(), + dispatch: vi.fn(), + upload: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); +const ownerId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + +vi.mock("@/lib/env", () => ({ + env: { + MAX_UPLOAD_MB: 150, + MAX_CONCURRENT_UPLOADS: 1, + MAX_IN_FLIGHT_UPLOAD_MB: 151, + SUPABASE_DOCUMENT_BUCKET: "clinical-documents", + SUPABASE_IMAGE_BUCKET: "clinical-images", + WORKER_MAX_ATTEMPTS: 3, + }, +})); +vi.mock("@/lib/logger", () => ({ logger: { info: mocks.info, warn: mocks.warn, error: mocks.error } })); +vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: mocks.admin })); +vi.mock("@/lib/supabase/auth", async (original) => ({ + ...(await original()), + requireAuthenticatedUser: async () => ({ id: ownerId, appMetadata: { site_role: "administrator" } }), +})); +vi.mock("@/lib/supabase/server", () => ({ + createSupabaseServerClient: async () => ({ + auth: { + getClaims: async () => ({ + data: { + claims: { + sub: ownerId, + role: "authenticated", + aal: "aal2", + app_metadata: { site_role: "administrator" }, + amr: [{ method: "totp", timestamp: Math.floor(Date.now() / 1000) - 5 }], + }, + }, + error: null, + }), + }, + }), +})); +vi.mock("@/lib/api-rate-limit", () => ({ consumeSubjectApiRateLimit: async () => ({ limited: false }) })); +vi.mock("@/lib/supabase/health", () => ({ probeSupabaseHealth: async () => ({ ok: true }) })); +vi.mock("@/lib/upload-structure", () => ({ assertUploadStructure: async () => undefined })); +vi.mock("@/lib/document-naming", () => ({ + planDocumentName: async ({ fileName }: { fileName: string }) => ({ + title: "Synthetic document", + baseTitle: "Synthetic document", + originalTitle: null, + originalFileName: fileName, + duplicateIndex: 1, + duplicateGroupKey: "synthetic-group", + duplicateReason: "none", + }), +})); +vi.mock("@/lib/trusted-document-admission", async (original) => ({ + ...(await original()), + dispatchTrustedDocumentAdmission: mocks.dispatch, + uploadTrustedDocumentBytes: mocks.upload, +})); + +import { POST } from "@/app/api/upload/route"; +import { TrustedDocumentAdmissionDispatchError } from "@/lib/trusted-document-admission"; + +// Wholly invented identifiers; deliberately not a pattern-dependent MRN redaction case. +const filename = "Synthetic_Mira_Quinn_DOB_1982-03-04.pdf"; +const providerText = `Provider refused owner/private/${filename}; contact synthetic-person@example.invalid; token synthetic-private-value`; +const authority = { + version: 1, + sourceRole: "clinical_guideline", + authorityBasis: "administrator_template", + licencePolicy: "review_required", + rightsEvidenceRef: "synthetic-rights-evidence", + publisher: "Synthetic Publisher", + jurisdiction: "Australia", + publicationDate: null, + effectiveDate: null, + reviewDate: null, + expiryDate: null, + currentness: "current", + supersedesDocumentId: null, + purpose: "clinical_retrieval", + supportedFeatures: ["text"], + providerProcessing: "permitted", +}; + +function request() { + const form = new FormData(); + form.set("file", new File(["%PDF-1.7\n%%EOF"], filename, { type: "application/pdf" })); + form.set("authority", JSON.stringify(authority)); + return new Request("http://localhost/api/upload", { + method: "POST", + body: form, + headers: { "Idempotency-Key": "11111111-1111-4111-8111-111111111111" }, + }); +} + +function client({ ledgerFails = false, documentFails = false } = {}) { + const remove = vi.fn(async () => ({ error: { message: providerText } })); + const insert = vi.fn(async () => ({ error: ledgerFails ? { message: providerText } : null })); + const maybeSingle = vi.fn(async () => ({ data: null, error: documentFails ? { message: providerText } : null })); + const query = { select: () => query, eq: () => query, maybeSingle }; + const from = vi.fn((table: string) => { + if (table === "storage_cleanup_jobs") return { insert }; + if (table === "documents") return query; + throw new Error("Unexpected test table"); + }); + return { storage: { from: vi.fn(() => ({ remove })) }, from, remove, insert, maybeSingle }; +} + +async function assertSafeFailure(operation: Promise) { + const settled = await operation.then( + (response) => ({ response, rejected: null }), + (rejected: unknown) => ({ response: null, rejected }), + ); + const body = settled.response ? await settled.response.text() : ""; + const diagnostics = JSON.stringify({ + body, + rejected: settled.rejected instanceof Error ? settled.rejected.message : settled.rejected, + logs: [...mocks.info.mock.calls, ...mocks.warn.mock.calls, ...mocks.error.mock.calls], + }); + for (const privateText of [ + filename, + "Mira", + "1982-03-04", + "synthetic-person@example.invalid", + "synthetic-private-value", + "Provider refused", + ]) { + expect(diagnostics).not.toContain(privateText); + } + expect(settled.rejected).toBeNull(); + expect(settled.response?.status).toBe(500); + expect(JSON.parse(body)).toMatchObject({ error: "Request failed.", code: "internal_error" }); + expect(JSON.parse(body)).not.toHaveProperty("duplicate"); +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.dispatch.mockImplementation(async ({ replayOnly }: { replayOnly: boolean }) => { + if (replayOnly) return { outcome: "not_found" }; + throw new TrustedDocumentAdmissionDispatchError("rejected", "duplicate_content"); + }); + mocks.upload.mockImplementation(async ({ intendedStoragePath }: { intendedStoragePath: string }) => ({ + storagePath: intendedStoragePath, + collisionRecovered: false, + })); +}); + +describe("upload diagnostics omit filenames and provider error content", () => { + it("fails closed with a safe response when duplicate storage removal and the exact-path cleanup ledger both fail", async () => { + const supabase = client({ ledgerFails: true }); + mocks.admin.mockReturnValue(supabase); + + await assertSafeFailure(POST(request())); + + expect(mocks.dispatch).toHaveBeenCalledTimes(2); + expect(mocks.upload).toHaveBeenCalledTimes(1); + const path = mocks.upload.mock.calls[0]![0].intendedStoragePath as string; + expect(path).toContain(filename); + expect(supabase.remove).toHaveBeenCalledExactlyOnceWith([path]); + expect(supabase.insert).toHaveBeenCalledExactlyOnceWith({ + document_bucket: "clinical-documents", + document_paths: [path], + owner_id: ownerId, + status: "pending", + image_bucket: "clinical-images", + image_paths: [], + }); + // Diagnostics retain an opaque correlation reference, while only the scoped recovery row gets the exact path. + expect(mocks.warn).toHaveBeenCalledWith("Duplicate upload storage cleanup failed", { + storageObjectRef: createHash("sha256").update(path).digest("hex"), + errorType: "UnknownError", + }); + expect(mocks.error).toHaveBeenCalledTimes(1); + expect(mocks.error.mock.calls[0]![1]).toEqual({ errorType: "Error" }); + }); + + it("logs only the content-free error envelope for an ordinary provider failure", async () => { + const supabase = client({ documentFails: true }); + mocks.admin.mockReturnValue(supabase); + + await assertSafeFailure(POST(request())); + + expect(mocks.upload).not.toHaveBeenCalled(); + expect(mocks.dispatch).not.toHaveBeenCalled(); + expect(mocks.warn).not.toHaveBeenCalled(); + expect(mocks.error).toHaveBeenCalledTimes(1); + expect(mocks.error.mock.calls[0]![0]).toEqual(expect.any(String)); + expect(mocks.error.mock.calls[0]![1]).toEqual({ errorType: "Error" }); + }); +}); diff --git a/tests/work-calendar-entries-enabled.dom.test.tsx b/tests/work-calendar-entries-enabled.dom.test.tsx index 717f6d0eda..3addf6d8d4 100644 --- a/tests/work-calendar-entries-enabled.dom.test.tsx +++ b/tests/work-calendar-entries-enabled.dom.test.tsx @@ -7,6 +7,7 @@ import { renderHook } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; const reads = vi.hoisted(() => ({ rotations: vi.fn(), bookings: vi.fn() })); +const failures = vi.hoisted(() => ({ on: false, rotations: vi.fn(), bookings: vi.fn() })); vi.mock("@/components/work-calendar/sources", () => ({ WORK_CALENDAR_SOURCES: [ @@ -14,14 +15,14 @@ vi.mock("@/components/work-calendar/sources", () => ({ id: "rotations", read: (enabled: boolean) => { reads.rotations(enabled); - return { status: enabled ? "ready" : "off", entries: [] }; + return { status: enabled ? (failures.on ? "error" : "ready") : "off", entries: [], retry: failures.rotations }; }, }, { id: "bookings", read: (enabled: boolean) => { reads.bookings(enabled); - return { status: enabled ? "ready" : "off", entries: [] }; + return { status: enabled ? (failures.on ? "error" : "ready") : "off", entries: [], retry: failures.bookings }; }, }, ], @@ -46,4 +47,16 @@ describe("useWorkCalendarEntries enabled", () => { expect(reads.rotations).not.toHaveBeenCalledWith(true); expect(result.current).toMatchObject({ status: "off", entries: [] }); }); + + it("retries only the named failed source and retains retry-all compatibility", () => { + failures.on = true; + const { result } = renderHook(() => useWorkCalendarEntries()); + result.current.retry("bookings"); + expect(failures.bookings).toHaveBeenCalledTimes(1); + expect(failures.rotations).not.toHaveBeenCalled(); + result.current.retry(); + expect(failures.bookings).toHaveBeenCalledTimes(2); + expect(failures.rotations).toHaveBeenCalledTimes(1); + failures.on = false; + }); }); diff --git a/tests/work-roles-hospital-sick-release.test.ts b/tests/work-roles-hospital-sick-release.test.ts new file mode 100644 index 0000000000..4316db92dc --- /dev/null +++ b/tests/work-roles-hospital-sick-release.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + admin: vi.fn(), + auth: vi.fn(), + roles: vi.fn(), + read: vi.fn(), + rate: vi.fn(), + demo: vi.fn(), +})); +vi.mock("@/lib/env", () => ({ isDemoMode: mocks.demo })); +vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: mocks.admin })); +vi.mock("@/lib/supabase/auth", () => ({ + AuthenticationError: class AuthenticationError extends Error {}, + requireAuthenticatedUser: mocks.auth, + unauthorizedResponse: () => Response.json({ error: "Unauthorized" }, { status: 401 }), +})); +vi.mock("@/lib/api-rate-limit", () => ({ + allowRateLimitInMemoryFallbackOnUnavailable: () => false, + consumeSubjectApiRateLimit: mocks.rate, + rateLimitJsonResponse: () => Response.json({ error: "Limited" }, { status: 429 }), +})); +vi.mock("@/lib/work-roles/server", () => ({ loadWorkRoleContext: mocks.roles })); +vi.mock("@/lib/work-roles/hospital-sick", () => ({ readHospitalSickCalls: mocks.read })); + +import { GET } from "@/app/api/work/hospital/sick/route"; +import { PublicApiError } from "@/lib/http"; +import { AuthenticationError } from "@/lib/supabase/auth"; + +const hospitalId = "11111111-1111-4111-8111-111111111111"; +const user = { id: "synthetic-workforce", appMetadata: {} }; +const context = { userId: user.id, ready: true, grants: [{ role: "workforce", hospitalId, serviceIds: ["team"] }] }; +const client = { synthetic: true }; +const request = () => new Request(`https://example.invalid/api/work/hospital/sick?hospitalId=${hospitalId}`); + +beforeEach(() => { + vi.resetAllMocks(); + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("ROSTER_TEAM_RELEASE_ENABLED", ""); + mocks.demo.mockReturnValue(false); + mocks.admin.mockReturnValue(client); + mocks.auth.mockResolvedValue(user); + mocks.roles.mockResolvedValue(context); + mocks.rate.mockResolvedValue({ limited: false }); + mocks.read.mockResolvedValue({ + hospital: { id: hospitalId, name: "Synthetic Hospital" }, + teams: [], + calls: [{ id: "synthetic-report", name: "Dr Synthetic Private Staff", status: "needs-cover" }], + }); +}); +afterEach(() => vi.unstubAllEnvs()); + +describe("hospital sick real-staff release boundary", () => { + it.each(["", "false", "TRUE"])("holds production without reading named staff data (flag %j)", async (flag) => { + vi.stubEnv("ROSTER_TEAM_RELEASE_ENABLED", flag); + const response = await GET(request()); + expect(response.status).toBe(503); + expect(await response.text()).not.toContain("Dr Synthetic Private Staff"); + expect(mocks.admin).not.toHaveBeenCalled(); + expect(mocks.roles).not.toHaveBeenCalled(); + expect(mocks.read).not.toHaveBeenCalled(); + }); + + it("preserves authentication, current role loading and requested hospital when explicitly enabled", async () => { + vi.stubEnv("ROSTER_TEAM_RELEASE_ENABLED", "true"); + const response = await GET(request()); + expect(response.status).toBe(200); + expect(mocks.auth).toHaveBeenCalledWith(expect.any(Request), client); + expect(mocks.roles).toHaveBeenCalledWith(client, user); + expect(mocks.read).toHaveBeenCalledWith(client, context, hospitalId); + expect(response.headers.get("Cache-Control")).toContain("no-store"); + }); + + it("still denies an unauthenticated enabled request", async () => { + vi.stubEnv("ROSTER_TEAM_RELEASE_ENABLED", "true"); + mocks.auth.mockRejectedValue(new AuthenticationError()); + expect((await GET(request())).status).toBe(401); + expect(mocks.read).not.toHaveBeenCalled(); + }); + + it("preserves the reader's hospital-role denial after enablement", async () => { + vi.stubEnv("ROSTER_TEAM_RELEASE_ENABLED", "true"); + mocks.read.mockRejectedValue(new PublicApiError("Role required", 403, { code: "work_role_required" })); + expect((await GET(request())).status).toBe(403); + }); + + it("still rate limits an enabled request before loading staff roles or reports", async () => { + vi.stubEnv("ROSTER_TEAM_RELEASE_ENABLED", "true"); + mocks.rate.mockResolvedValue({ limited: true }); + expect((await GET(request())).status).toBe(429); + expect(mocks.roles).not.toHaveBeenCalled(); + expect(mocks.read).not.toHaveBeenCalled(); + }); + + it("never reads real staff in demo mode even with the release flag", async () => { + vi.stubEnv("ROSTER_TEAM_RELEASE_ENABLED", "true"); + mocks.demo.mockReturnValue(true); + expect((await GET(request())).status).toBe(503); + expect(mocks.admin).not.toHaveBeenCalled(); + expect(mocks.read).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/work-roles-hospital-sick.test.ts b/tests/work-roles-hospital-sick.test.ts index 755d05a0f5..b8c91348df 100644 --- a/tests/work-roles-hospital-sick.test.ts +++ b/tests/work-roles-hospital-sick.test.ts @@ -44,6 +44,23 @@ const shift = { }; describe("hospital sick calls", () => { + it.each([ + { userId: "unprivileged", ready: true, grants: [] }, + { + userId: "wrong-hospital", + ready: true, + grants: [{ role: "workforce" as const, hospitalId: "h2", serviceIds: ["t2"] }], + }, + { userId: "team-manager", ready: true, grants: [{ role: "manager" as const, serviceId: "t1" }] }, + ])("rejects a caller without a current hospital-level role ($userId) before reading staff", async (context) => { + const from = () => { + throw new Error("Denied requests must not query staff tables"); + }; + await expect( + readHospitalSickCalls({ from } as unknown as Parameters[0], context, "h1"), + ).rejects.toMatchObject({ status: 403, details: { code: "work_role_required" } }); + }); + it("lists only reported shifts and names the doctor rostered when it was reported, not the poster or the cover", async () => { const calls: string[] = []; const client = fakeClient( diff --git a/tests/work-roles-people-changes.test.ts b/tests/work-roles-people-changes.test.ts index 9f1c7372df..b661a20518 100644 --- a/tests/work-roles-people-changes.test.ts +++ b/tests/work-roles-people-changes.test.ts @@ -14,6 +14,7 @@ import { loadWorkRoleContext, type WorkRoleContext } from "@/lib/work-roles/serv type Row = Record; type Tables = Record; +type QueryResult = { data: unknown; error: { code: string } | null }; const H1 = "11111111-1111-4111-8111-111111111111"; const H2 = "22222222-2222-4222-8222-222222222222"; @@ -24,6 +25,7 @@ const ADMIN = "99999999-9999-4999-8999-999999999999"; const WORKFORCE = "88888888-8888-4888-8888-888888888888"; function fakeDb(tables: Tables) { + const state = { rpcUnavailable: false }; const writes: { table: string; kind: "update" | "insert"; values: Row }[] = []; function nameTaken(table: string, values: Row, self: Row | null): boolean { @@ -42,7 +44,7 @@ function fakeDb(tables: Tables) { let single = false; let max = Infinity; - function run(): { data: unknown; error: { code: string } | null } { + function run(): QueryResult { const rows = (tables[table] ??= []); if (mode.kind === "insert") { if (nameTaken(table, mode.values, null)) return { data: null, error: { code: "23505" } }; @@ -56,7 +58,25 @@ function fakeDb(tables: Tables) { for (const row of matched) { if (nameTaken(table, mode.values, row)) return { data: null, error: { code: "23505" } }; } - for (const row of matched) Object.assign(row, mode.values); + for (const row of matched) { + const oldHospital = row.hospital_id; + Object.assign(row, mode.values); + // Model the database-owned atomic move/revocation contract. Actual trigger SQL + // failure and concurrency are exercised separately on PostgreSQL. + if (table === "work_hospital_teams" && oldHospital !== row.hospital_id) { + for (const grant of tables.work_role_grants ?? []) { + if ( + grant.role === "supervisor" && + grant.service_id === row.service_id && + grant.subject_user_id == null && + grant.revoked_at == null + ) { + grant.revoked_at = row.linked_at; + grant.revoked_by = row.linked_by; + } + } + } + } writes.push({ table, kind: "update", values: mode.values }); } return { data: single ? (matched[0] ?? null) : matched.map((row) => ({ ...row })), error: null }; @@ -93,7 +113,7 @@ function fakeDb(tables: Tables) { single = true; return query; }, - then: (resolve: (value: unknown) => unknown, reject?: (reason: unknown) => unknown) => + then: (resolve: (value: QueryResult) => unknown, reject?: (reason: unknown) => unknown) => Promise.resolve(run()).then(resolve, reject), }; return query; @@ -116,9 +136,18 @@ function fakeDb(tables: Tables) { }), }, }, - rpc: async () => ({ data: null, error: null }), + rpc: async (name: string, args: Record) => { + if (state.rpcUnavailable) return { data: null, error: { code: "PGRST202", message: "Missing move RPC" } }; + if (name !== "work_move_team_v1") throw new Error("Unexpected RPC"); + const result = await from("work_hospital_teams") + .update({ hospital_id: args.p_to_hospital_id, linked_by: args.p_actor_id, linked_at: new Date().toISOString() }) + .eq("service_id", args.p_service_id) + .eq("hospital_id", args.p_expected_hospital_id) + .select(); + return result.error ? result : { data: args.p_expected_hospital_id, error: null }; + }, }; - return { client: client as unknown as Parameters[0], tables, writes }; + return { client: client as unknown as Parameters[0], tables, writes, state }; } function seed(): Tables { @@ -206,6 +235,18 @@ describe("the change schema", () => { }); describe("move a team to another hospital", () => { + it("refuses before moving when the required transaction RPC is absent", async () => { + const db = fakeDb(seed()); + db.state.rpcUnavailable = true; + const before = structuredClone(db.tables); + expect( + (await refusal(applyPeopleChange(db.client, admin, { action: "move-team", serviceId: TEAM, toHospitalId: H2 }))) + .status, + ).toBe(503); + expect(db.tables).toEqual(before); + expect(db.writes).toEqual([]); + }); + it("moves the link, removes only whole-team supervisors, and answers with the hospital it left", async () => { const db = fakeDb(seed()); // A supervisor of a named trainee in this team stays: scoped to the old hospital, it just stops matching. diff --git a/tests/worker-python-lock.test.ts b/tests/worker-python-lock.test.ts index 51cb2c6df6..bfbcc3cbf4 100644 --- a/tests/worker-python-lock.test.ts +++ b/tests/worker-python-lock.test.ts @@ -2,6 +2,28 @@ import { existsSync, readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; describe("worker Python lockfile", () => { + it("keeps the Cloud Cython pin compatible with quicksectx 0.4.1", () => { + const contents = readFileSync("worker/python/requirements-cloud.txt", "utf8"); + expect(contents).toMatch(/^quicksectx==0\.4\.1\s/m); + const cython = contents.match(/^cython==(\d+)\.(\d+)\.(\d+)\s/m); + expect(cython, "Cloud lock must pin Cython").not.toBeNull(); + // quicksectx 0.4.1 declares Cython>=0.25,<3.2.0 in its package metadata. + const [, major, minor] = cython!; + expect(Number(major) > 0 || Number(minor) >= 25).toBe(true); + expect(Number(major) < 3 || (Number(major) === 3 && Number(minor) < 2)).toBe(true); + }); + + it("keeps the Cloud Thinc pin compatible with spaCy 3.8.16", () => { + const contents = readFileSync("worker/python/requirements-cloud.txt", "utf8"); + expect(contents).toMatch(/^spacy==3\.8\.16\s/m); + const thinc = contents.match(/^thinc==(\d+)\.(\d+)\.(\d+)\s/m); + expect(thinc, "Cloud lock must pin Thinc").not.toBeNull(); + // spaCy 3.8.16 declares thinc>=8.3.12,<8.4.0 in its package metadata. + const [, major, minor, patch] = thinc!; + expect([Number(major), Number(minor)]).toEqual([8, 3]); + expect(Number(patch)).toBeGreaterThanOrEqual(12); + }); + it("has hashed, pinned locks for production Python 3.11 and Cloud Python 3.12", () => { expect(existsSync("worker/python/requirements.in"), "requirements.in missing").toBe(true); for (const [file, version] of [ diff --git a/worker/main.ts b/worker/main.ts index 0ed79af99c..fafed89e39 100644 --- a/worker/main.ts +++ b/worker/main.ts @@ -38,7 +38,7 @@ import { import { shouldPersistJobProgress } from "../src/lib/ingestion"; import { assessDocumentIndexQuality } from "../src/lib/index-quality"; import { classifyAndCaptionImageFromBase64, embedTexts } from "../src/lib/openai"; -import { safeErrorLogDetails, redactCaptionIdentifiers } from "../src/lib/privacy"; +import { contentFreeErrorDetails, safeErrorLogDetails, redactCaptionIdentifiers } from "../src/lib/privacy"; import { isAtomicReindexCandidate } from "../src/lib/reindex-pipeline"; // Deliberate exception to the "ingestion does not import rag.ts" rule in the archived // architecture handover: after a document commits, the worker must drop that owner's cached @@ -2496,22 +2496,28 @@ export async function runWorkerMain() { const abort = error instanceof WorkerAbortError ? error : null; console.error( abort ? "PsychSift worker aborted" : "PsychSift worker stopped unexpectedly", - safeErrorLogDetails(error), + contentFreeErrorDetails(error), ); captureWorkerException(error, "fatal"); + const failureDetails = contentFreeErrorDetails(error); + // WorkerAbortError wraps the underlying failure in `cause`; read its allowlisted code too. + const failureCode = + failureDetails.errorCode ?? + (abort?.cause ? contentFreeErrorDetails(abort.cause).errorCode : undefined) ?? + "unclassified"; if (env.WORKER_FAILURE_WEBHOOK_URL) { try { await fetch(env.WORKER_FAILURE_WEBHOOK_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - text: `CRITICAL: PsychSift worker ${abort ? "aborted" : "stopped unexpectedly"}. Error: ${error instanceof Error ? error.message : String(error)}`, + text: `CRITICAL: PsychSift worker ${abort ? "aborted" : "stopped unexpectedly"}. errorType=${failureDetails.errorType} errorCode=${failureCode}.`, }), // Bounded so a stalled endpoint cannot hold the exit, and with it Railway's restart. signal: AbortSignal.timeout(FAILURE_WEBHOOK_TIMEOUT_MS), }); } catch (webhookError) { - console.error("Failed to dispatch worker failure webhook", safeErrorLogDetails(webhookError)); + console.error("Failed to dispatch worker failure webhook", contentFreeErrorDetails(webhookError)); } } // Flush last: the process is about to exit and buffered events would be lost. diff --git a/worker/python/requirements-cloud.in b/worker/python/requirements-cloud.in new file mode 100644 index 0000000000..5a2341af79 --- /dev/null +++ b/worker/python/requirements-cloud.in @@ -0,0 +1,6 @@ +# Optional Python 3.12 bootstrap dependency input; production uses requirements.in. +-r requirements.in + +# quicksectx 0.4.1 (via medspacy-quickumls) requires Cython>=0.25,<3.2.0. +# Keep the resolver's build/runtime Cython selection within that supported range. +Cython>=0.25,<3.2.0 diff --git a/worker/python/requirements-cloud.txt b/worker/python/requirements-cloud.txt index ec68793505..2514da88a2 100644 --- a/worker/python/requirements-cloud.txt +++ b/worker/python/requirements-cloud.txt @@ -339,47 +339,81 @@ cymem==2.0.13 \ # preshed # spacy # thinc -cython==3.3.0 \ - --hash=sha256:03056533fe4fdbc4f1d34a39178f9a4937ff35196f8bcdde2a67b5b5809c61fe \ - --hash=sha256:03bc5333932f5dda3ba9315298ecdd21daa1b58410bb1f8ce04c78ec8337130a \ - --hash=sha256:0507d9caf7dc35f1212627145d5d13dbc5dd7128529a6608ab72690472fa688e \ - --hash=sha256:0deedc2e9a5a664e1adfa4c2d310aa7b54903e1a647c274b6c9213f77a02d637 \ - --hash=sha256:11e437f086affee8051cec4bb531be3edb646ab66e325154aa6849377f365033 \ - --hash=sha256:14e825253455e943ca765a95096b355745558436b0c46c24856de9269cc4dbd9 \ - --hash=sha256:169e56fd411f4cd5bba51c82f8239421d547a846099db2b261e4aed48ba9f51f \ - --hash=sha256:23942b0662642927a55676e4b26e6840fb166dd7d76436384685227e7e8619a4 \ - --hash=sha256:26a5e536fc68e85a9de091a0b51c42c5ac834f8d00aaa43f227cbc3efa797ae5 \ - --hash=sha256:29f38ebafdf23e3da2516f40c4d065da38bfe002181bf93e2b8cf1262449aba6 \ - --hash=sha256:311a016369adfd1e0015c4f9819168fc0e518451d7efb4435c30d65a3a26d52b \ - --hash=sha256:333449cc0350baedee5a6af27929eac8a71eac4ec59333c45ff476b33c6c660d \ - --hash=sha256:428fafed98ea26927000a287b4dfc9ef07339f56656a5329a34eaa593f79a4f8 \ - --hash=sha256:46072c0d404616b5e652a63882c79cc3f8a1d62635a8692f56ed0e416a4dfed8 \ - --hash=sha256:4a36c34d1950845b8ac148653b07cdc62421a4b0d9abfcc849e69f1c4ff9919d \ - --hash=sha256:51999fb834365721b6c7f689cf6e2ec7c8667aae783df9eb5e589c290a414d9c \ - --hash=sha256:596e8df019372a2cd417805015022d42cb8ee4e1803ccdc11ed00e451625fb66 \ - --hash=sha256:66d86b6a1548ae64851b211e3c3504535814b8c8e6c46ddcaf01062bf8d5fad2 \ - --hash=sha256:75c4ae8a6d3a5ccf3cdaba8ab32e6a8d0cd38e3a476aa7ac12df8f8171a8d570 \ - --hash=sha256:7e321ae700995a16dc3055ada06ffb8d61e1a7434e5d0e811547a45ac1015ebd \ - --hash=sha256:82f94565b6001bab8e31bf52a0911672910b5735910612a2c0f772c719670006 \ - --hash=sha256:843d7134e784e7b320ef387512e89f1b29af80c641e176dfa8eabd52aab61c3c \ - --hash=sha256:8566ea804cfc265f5e9dda71d1b716aa24ee4c3423a5da4b28a248a78c33e3f9 \ - --hash=sha256:90869072e50b7c8904fe1dd7810321ae901fd5637a6eec6646ed9c57f9eb1081 \ - --hash=sha256:9b24b5c8cd536946b62086fcafee6d5509d3f549f72d553d2336af87ffbe0da1 \ - --hash=sha256:ab24d1a4fb6aaf0b5b6fcd75a6d70255fbd3130fa78884c26991f8d5502616b5 \ - --hash=sha256:b447f6906e0555f05dc4742ef1f99091b1e5d9aa9f16616e772fbf9ff6271616 \ - --hash=sha256:b55c72e8eccdd508c8de3cf3bbc543aafbb3bf6a518e1ee20358d3241cd780ef \ - --hash=sha256:b94fb5613b9fe34c27d13ec9972dc0dcd2a2155db2902e93921cadc162610a38 \ - --hash=sha256:bc2f2a6b65a991666cfd35a35bab0cd88ffba4df2f601edb6e76cc8116de24b9 \ - --hash=sha256:bf411da3ef1af8763781c219108860f7de33f1100038da35d6bf1b4d83fcb2c0 \ - --hash=sha256:c4558ba85849ab65dc57e10fd0efb13fabd9d3c09981a2566e18dec7cf47586a \ - --hash=sha256:dce56c26d388f00a19426371b6926bf2f77c5c03b71d5273e4556c68be98c2dd \ - --hash=sha256:de883ec6764b61547c1e7674c0d8a8a875d398bd6bb684e46b93d83e4f13b260 \ - --hash=sha256:e0d2713d2b292c826bc21dc8732bd9e47628103aa3764180c881e04b3fef95dc \ - --hash=sha256:e6035b5231a9316edc19d6415f4296fd1d0370e2a165a714b3edc167b9ca00e1 \ - --hash=sha256:ec09dbf73ff4f7be2b339b995fadae9c4bb517bbbed7ec11d6fe99c2092b48fd \ - --hash=sha256:eda47eb7731c3b41180b58bb83de423f43aa58a677677e3390e8d332b003859e \ - --hash=sha256:eed0d93fbca7087f143b42c34b05a825849bdf17f101572c2105acfa49aa88b8 +cython==3.1.8 \ + --hash=sha256:0274d3c2f95f79784bccd5454ab9dd380aa5ce237e1dc685cc1ce149ac2ffbc2 \ + --hash=sha256:03959ed8996e16550267469187442f3926ea3ba4bb220a06714e6203e4cb6017 \ + --hash=sha256:062f1179af6c5eba92be6eef52baf46eb9cf0677a53aa6bf96ba7861482947a8 \ + --hash=sha256:08ba4010e2a96112c1cc3063c0f96246c33fdaeb937572a675229204745207ad \ + --hash=sha256:0aa5443cc8bfc937369871fbba59990b36cd9af73ed70db9052ac980d44492e4 \ + --hash=sha256:0bc71b05497608c1f8dc99340055c0456f712ce8a1cf35391c9134bbd037f7c6 \ + --hash=sha256:1be05353f84e6a32cbae5869da1af72d356cc73a6dfa521423543688f6b59d67 \ + --hash=sha256:1cbb54f823c6f06d05ec891de0b09ec99cf4fe64365c2b54ce2c918f7d209a4f \ + --hash=sha256:1d04bd43b80ed9ab6228193f24f287fe98b839270625051f7705cedd4e76ac8e \ + --hash=sha256:282b3c8e6abc3fea421919e862e898ffdd86fc0796009bdb5ffdf8211413219f \ + --hash=sha256:306cec0045c18b23152882a583c6181fa8d0c069a4aabf6e6b12bafb8600eed4 \ + --hash=sha256:34eb6dfaa0786a7477aaac86696f26efc052cda586d4f97a9d1d2c6b2ff6d26d \ + --hash=sha256:354f43c6d0e312f650ed39c683e550f669ed18f49ca0bd5772f207ef31f8980c \ + --hash=sha256:3a9eb5545c9ef0f5f6cfb864f70262e284df270de8cd5d73ee2097af0de432a6 \ + --hash=sha256:3d7df7d45733460167d96d9c093fe0abf5d4898aca8f05b7e4cd397e9c8f1d2e \ + --hash=sha256:45a5e806fbfb37c603c4cf0e60b7cd1165ad66bc3aa206f379606ca34dd93c42 \ + --hash=sha256:4673f213e692b9502de8d25cd8f2d8aa3c0ea096546b5f6f019a7ec55df0a56d \ + --hash=sha256:4fa23531b04d63ba2fac0dbf577323ac5c40358ea3ec6bc2b98445bf418faca7 \ + --hash=sha256:50ec9dac15bd8bdb276fd38a146c5294184330551bc1fb053e7e04ff66b7c756 \ + --hash=sha256:518475fbe633ba30c1577669f417440f41e1a23ad6b6e2dbb20703cd457f04ef \ + --hash=sha256:53e5bbf18c274d99dec3e75e1ef19c0add393c8fa14e3c5f1f7ed7fb88e9621d \ + --hash=sha256:554ce2d8243e9330c21ed6e8a5472f1fa812afb99794581c4276b88bd523af5d \ + --hash=sha256:58e21933b3b568817a2896767577ff8a31a246f5c6a5589c73469d83f2bcbfd5 \ + --hash=sha256:5e10d15d2e7f245e61dec89c27da377c2136c08efb6cc2c50baea25b39aab3f9 \ + --hash=sha256:6221417b160e9172aae69b0fd397e02528a6076e1fec642a181aa11c64e32cc9 \ + --hash=sha256:658b8d9f7261a79aa66cf2a5eb0fd7a9b8e196f21671bfc945765a615205d775 \ + --hash=sha256:65d0cf78a14616cb05ff21f63b47bf369e23f353acd51420526c5eb3029f682a \ + --hash=sha256:6ee0772182f6c943d8224a043f00da2c1a8f62cae7de13d02044ef7865035745 \ + --hash=sha256:70ac13ab4bf7e9c1285172d36f9bb06644bb84dc6d4fc0e156ffb8d04b9df86a \ + --hash=sha256:791414cca4dc04eb95b928da88154d8bf5164f85a645816291e2f549c5e8b935 \ + --hash=sha256:7cca0af72f79866994aa7e79ee72d11b8618313c2a4f9405a6943b3e1186952e \ + --hash=sha256:7eb8448eb1c3632c4684ee98db935cf7d3b54fb528a7a455841789316667cd88 \ + --hash=sha256:850503d88092af519662cf66aef26f95db677bdbba55a482ae8658c5b3ac6ad4 \ + --hash=sha256:85c39a8005f314ee90ab271d2b68b6725f94fc08c4e6728417b64704a7ebce04 \ + --hash=sha256:8d10dc3b84b1fe6a7edd43c5bb09cbbf98a7e14f235972e895d029dfcb0a9ce0 \ + --hash=sha256:8f827bf462359eec838e4462c1d819e0008e82dc53df107be13e7617bc96821d \ + --hash=sha256:92a8df673744117afe2a4cb979887ca9440f9e59cbb2e9d0753db2194e2a5080 \ + --hash=sha256:98f45565771122c3f1604b057a5c506a7e682f99ec9d44197cb2990117ff4869 \ + --hash=sha256:9f9af578c0a3ae0a10a8cf41805d0dbec173f909a297368f30090a55a5a9cbe0 \ + --hash=sha256:a9288fa02135743719942deaeb44976399a5785a59f076221ef7e9b22cd0d571 \ + --hash=sha256:b008f7802789bafcdd61de66e11697ee6807839154fc1bf72d81705500997551 \ + --hash=sha256:b690edfa6b333f6139e8ecaa5af8fe87ea591f04d2f2b6a492ed4f2896d818c4 \ + --hash=sha256:bbd835515316a0d014ce27256fb7ceda052d14ac32c242d3f1d6d41734ba3c28 \ + --hash=sha256:bc4fdb56b64f035025bf6958cbd22a61fce1668a2b19ec47aa418df5e9b6b61d \ + --hash=sha256:c27c5c5d675ecabfce60f27a24024df20472b90735d57ce3b869685606b31617 \ + --hash=sha256:c6b74c42bf6b2381d9fdb65f7eda44bcc5c29e4e96c677167495d2cd882d7a3c \ + --hash=sha256:c8803c6b42d482f894cd2ff50f334cc27f70029a9177f7183cac1e5da3978edd \ + --hash=sha256:cae61e8e730bfc7de917204c8b7c1ca8e3e0d134dcaa55a2e2ef98de741590fb \ + --hash=sha256:d114a15b1660ffc8456baf6bfff836fbe2f9c523954de6913040cf6cea1b2349 \ + --hash=sha256:d1bcbed7578f1986a61471476ea5deb0806591aa477230954dd9898a25d0f4e2 \ + --hash=sha256:d249b8ba93cdc0172de325a48f122feec73c03f31d88a738995499a2153f98c4 \ + --hash=sha256:d379eb20a48186573c1371b9f7ae45486f3cc378c3b8f5a38e005cb907596ef2 \ + --hash=sha256:d9029c0eb895a56746ca0d4b3a6a0a57e4c90f2599204e99f7033818ea1c3022 \ + --hash=sha256:d973d25a767abcf977737221dc11befda98daf2573e8bc3c2e6995479505f92a \ + --hash=sha256:d9cd8d1194ab8f52898be10d252ff17360e0af0e2a6662a8d9688fdd8e4cdd7e \ + --hash=sha256:ddab120e53aab67c098416ac5a1467a01f03360aa75eb21a0064b85e9d621be7 \ + --hash=sha256:ddc98eea4a396e903c47ecb8a9defa39b951dd8ccae40e425fe32ec953e228d8 \ + --hash=sha256:de5613a09f3cf056561528a0146aa3529920ce711728fb28d5399180c52ec520 \ + --hash=sha256:e04bb68f932853a38bab7f7c024088a9f6510ccb45ad9a12cca6ebb09ec7a25f \ + --hash=sha256:e153a5aa8f96b79f6e4b9a3d221d4f38b0f7f00a1462dec01478ab99075abd4a \ + --hash=sha256:e1715bf5838c43ef3abfc41d6552a1a5d4bc4d7b041a5d6833080d6b514cc590 \ + --hash=sha256:e26239169b0332eda9a00de22db1fb9d5eb51b67f0af6f19a45205bd119a2e12 \ + --hash=sha256:e3d59036be47961fba075067847abd16c82f5a45ea96ba709b1c485910c20e2e \ + --hash=sha256:e6f3a46fdc5b6da9481b81da6e0f8f3c04cb161baf6956e185a4f242fa1242e2 \ + --hash=sha256:ec653d499d02ada3d150894e840018a9fc82cc5f8b9ac3af00066a8c1f76e0bd \ + --hash=sha256:ef3000be84941e5b597579d3f753460d1e058c32a8697041f78a827797037280 \ + --hash=sha256:f36368c1d7ea4003c52e9e93f3556074a631a6482f8a32e781cb073e211e4df9 \ + --hash=sha256:f519dd0b076bc78edf3b7ab0722638b42d5ab43323654fbfe2fbf63216a3e854 \ + --hash=sha256:f6afffe05b2db113f6502c5b652e0ad5158584e185a51538beeffa8771bdb94d \ + --hash=sha256:f7c3678b89689a367c19fc7c09220a077c6c31c710728ab1aaf4b114c830ee15 \ + --hash=sha256:fc28b2933f2c3c897e1a27d0dee909d9731fa1d19fda3f1d8908dddf31825d2f \ + --hash=sha256:fc3cbaee08d424d2852c7b134ca432501792f67021b73a029cf2f13fb6172423 # via + # -r worker/python/requirements-cloud.in # pyfastner # pyrush # quicksectx @@ -1583,36 +1617,47 @@ srsly==2.5.4 \ # spacy # thinc # weasel -thinc==9.1.1 \ - --hash=sha256:082d3804a3dc5091bc3c980f338b37d4d848454f018b42be7dec7c042847c3c4 \ - --hash=sha256:09c15bae71d5ee215c7b5240eee859a16713de47e9250879a207f043e0c299b3 \ - --hash=sha256:19928387bf7e80e2f808fa02c268b4e593d118f4722b3c1df1d7f53ef79d410c \ - --hash=sha256:1b680e45eea8183984a717825b5f7594430674d019b66e4f959f6aba54d27425 \ - --hash=sha256:21fae2986d7777a6f050b9046dc9eab11852f1c9a997dcc9032f3ecc22784a4a \ - --hash=sha256:2b47bd3af0f68f52393a6ae76d28718260533fcf9f5e010a94a302e67611841c \ - --hash=sha256:34fe74c3d6682983fcbff49634f77c353e48fa6fd4b9e0fbd4075cec583523b5 \ - --hash=sha256:3d305f06b1dedd40ee942f629c63cd36b299e4368b9b1223f8952366e6b3ed3a \ - --hash=sha256:3e962f139b3e353d58ef87a861ba7e890bb29655c317bd225b568b97272a4b99 \ - --hash=sha256:45379a56d569f0a56a90d15fded5e46c8b1abb0a1b2617450194f06a55abf827 \ - --hash=sha256:49f316e599d71a91d6b0d8737015c7cc04cd8a10ca0ec412abc66212a4011dd4 \ - --hash=sha256:4ab269b1646c5cc058055d4941e343aa52b293225c31775a94373e02bc5c85a1 \ - --hash=sha256:4fa86a66854851ff51f7f5d32afe4c284582fc840009f11b9070f6aaf9455006 \ - --hash=sha256:7264dedc732541fd2783055072580b0eb06aecd9c102de639f2f53b6078127d1 \ - --hash=sha256:754fb8c6d3f9f7ba9503a8969f007d3a171d49989aca5a435511f06c5e1171a0 \ - --hash=sha256:7bd43399c9f3b6f5ec2e429a96cb7e2320c7f8db4a358715ba177b5a1325f0c7 \ - --hash=sha256:8a458c2ce71d74615acf491b717342821133eca4a36d172a8c048b46a92566bd \ - --hash=sha256:9c4a19eab36f246f49c3fd662a4442e07839a3f93a34b48867c9857587f9c518 \ - --hash=sha256:9c88e10e75607869425ed11d29c9a0754c5a8ed44048ea7bda00fb49a2d287a0 \ - --hash=sha256:a540ce68287939d5fcbc25e67c18d0e8b5e9f700e7075c28c2e40aa51496d54f \ - --hash=sha256:b03c7f88363bbbcef2a36b8da261c1ad69972ffe2f6c2aaf1accef5c2d749d1a \ - --hash=sha256:b3f6dceb9605f5aaa2b57e16b336866e8bed235fe1224612e51657c16e815480 \ - --hash=sha256:c7aef2cf91f53bebe2be52ff46f7b698da32e04e2ee53929aab6b1ff1af8157d \ - --hash=sha256:cf25ac6bf36bc8f46b7a1234a2be74469fe54fa6d1421fbb9deb968614ab03a6 \ - --hash=sha256:d3f16766f25cdf11fb136688d52df5d4698c30532cf42812192186b476833a33 \ - --hash=sha256:d4c30a139df83f076b79b8701ab5544ef49d683696d1a71cc5adcf2d24d73528 \ - --hash=sha256:e77f0877c567103601e58178bb0d24d227a081380e25ed8ab4d2f2f7438d6d6a \ - --hash=sha256:f1f9844a881f5892a23e23f0a112be3a6b0feedd4ac928f96ce32c6784fcaeb8 \ - --hash=sha256:fc24dadedfe2eeaa1c6a4a1b08d30a4fa81185527dfa5fdf602e334b23bc0911 +thinc==8.3.13 \ + --hash=sha256:0355c37e40d1a9fc2a1b8e9c2e294d8586f6baa97bcac6b9002f2dddb4b82ae9 \ + --hash=sha256:0a0fa13dcfe4b319c3a396432c1dbff30d3de37dbbdee559e76600ee2b9486df \ + --hash=sha256:11754fada9ad5ba2e02d5f3f234f940e24015b82333db58372f4a6aedad9b43f \ + --hash=sha256:303477eb51b9b39c94a7fc7967ee8a039eca1ca37d95dcce1234c83b95b4ee9f \ + --hash=sha256:3710d318b4e5460cf366a6f7b5ddbefb5d39dbd4cfa408222750fdc6c27c4411 \ + --hash=sha256:3dac18a0fb0a42f711c2ce9c02cbb090385aecae92089aa17b9dfd808a542013 \ + --hash=sha256:433e3826e018da489f1a8068e6de677f6eff3cc93991a599d90f12cd1bc26cdc \ + --hash=sha256:4565102638038a01a2193c7f5d41ccbd6233fbdcb1f1b184322a06add4f51f18 \ + --hash=sha256:4b5ec9ff313819e7d8667794a3559463fa89ff45aaa73e3fd8d6273b1e0d7a7f \ + --hash=sha256:5593e6300cb1ebe0c0e546e9c9fb49e7c2627a0aa688795cd4f995a8b820d2ec \ + --hash=sha256:565300b7e13de799e5abff00d445f537e9256cf7da4dcb0d0f005fc16748a29e \ + --hash=sha256:5a08c87143a6d20177652dca1ec0dc815d88216d8fc62594a57e8bc45bf5ed49 \ + --hash=sha256:5c9a48f2bc1e04f138240ed5f9b815a9141a5de26accd0f08fa0137fcefed258 \ + --hash=sha256:68e658549fc1eb3ff92aed5147fcbb9c15d6e9cc0e623b4d0998d16522ffb4f9 \ + --hash=sha256:723949cab11d1925c15447928513a718276316cec6e0de28337cca0a62be0521 \ + --hash=sha256:77a41f66285321d20aaedaea1e87d7cd48dca6d2427bed1867ec7cba7109fc8d \ + --hash=sha256:79a29a44d76bd02f5ac0624268c6e42b3576ae472c791a8ae9c2d813ae789b59 \ + --hash=sha256:7a99d0e242d1ccd23f9ae6bea7cd502f8626efa65c156b91d84581d0356696c3 \ + --hash=sha256:7badb0be4825535e6362c19e8a41872b65409e9da46d3453a391b843a0720865 \ + --hash=sha256:81337dfbee37f58f36c0c70f9a819dce1b32cdc13d959181e10de079621f6ac6 \ + --hash=sha256:84fb50fe572a1860165f2e7a640c7cb70d43d6962366e69f643fa9a27e4a2127 \ + --hash=sha256:859fbd9d9b16af5278da23589b4afbe2ab6b0dd615df4d3229b7c4e67cd3107e \ + --hash=sha256:8ad40307f20e83f77af28ff5c6be0b86af7a8b251d1231c545508d2763157d8f \ + --hash=sha256:a518d5c761a0f2341e530e867de133dc3ed814558365b2a68ec53b89c482a43f \ + --hash=sha256:a61a31fd0ce3c2771cf4901ba6df70e774ffe32febf1024c5b43d63575cd58fe \ + --hash=sha256:ba8119daf84a12259ae4d251d36426417bafa0b34108890b4b7e2b50966bd990 \ + --hash=sha256:c17cef1900a1aba7e1487493d16b8aa0a8633116f1b2a51c6649a4000697f17b \ + --hash=sha256:c2811dfd8d46d8b5d3b39051b23e64006b2994a5143b1978b436938018792af8 \ + --hash=sha256:c6a049703a6011c8fe26ee41af7e70272145594140d82f79bb23de619c6a6525 \ + --hash=sha256:cd8a2b714c061969eee65802965167a6ada1fe708d82fe176d98dcb95ebe182a \ + --hash=sha256:d7a9654f9ca362a4be7f5e590fdfee26e2e2084da9fd3306032ec037e99f2f8e \ + --hash=sha256:e08b1577a56e7315770af280aabd8fa5f2a1fb6afd1c50a4183c06e907faf558 \ + --hash=sha256:e1f8d13bf92ee10595c40692fd4cf8e7bbe73bd9f260107e975fd5dbee1af42b \ + --hash=sha256:e676edd21a747afbe3e6b9f3fca8b962e36d146ded03b070cb0c28e2dfbe9499 \ + --hash=sha256:e7f046d8914055cad51e83ff0da1a892acb73cd58556d7c1a5d4015a3766a899 \ + --hash=sha256:e9c7c5c104737b414c8c4ec578e67d78b6c859afe25cbc0684402e721415bd7f \ + --hash=sha256:ed1dc709ac4f2f03b710457889e4e02f05de51bc8456980c241d0b28798bc7cb \ + --hash=sha256:f4f26d1eec9b2a6a8f2e0298a5515d13eb06d70730d0d9e1040bb329e12bf3fb \ + --hash=sha256:f697174d3fb474966ce50b430bbafa101a6d2f7ffb559dac4b5c59389ef72d22 \ + --hash=sha256:fbc0ee16edd260c6a4a9e365ff36d0a682c9e7ca6d7b985682659ef2e3e73826 # via spacy tqdm==4.70.1 \ --hash=sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73 \ From 6f02779fd22e92be20c947fa639726344e1f067a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 12:30:33 +0000 Subject: [PATCH 2/2] Carry #3650's RAG cache evidence-revision fix into this rebuild #3650 merged into the old #3640 branch after this rebuild was cut. This applies exactly that branch's new changes, so the tree now equals the old branch merged with main. Includes migration 20261018095804 and its seal entry, which the owner approved by name. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018UBtJDgQZJ3eBsxLWte9r4 --- docs/codebase-index.md | 2 +- supabase/applied-migration-hashes.json | 5 +- supabase/drift-manifest.json | 94 ++++++++++++++++++- ...5804_rag_cache_scope_evidence_revision.sql | 91 ++++++++++++++++++ supabase/schema.sql | 93 ++++++++++++++++++ tests/rag-cache-governance-revocation.test.ts | 25 +++++ .../sql/rag-cache-scope-evidence-revision.sql | 73 ++++++++++++++ 7 files changed, 377 insertions(+), 6 deletions(-) create mode 100644 supabase/migrations/20261018095804_rag_cache_scope_evidence_revision.sql create mode 100644 tests/sql/rag-cache-scope-evidence-revision.sql diff --git a/docs/codebase-index.md b/docs/codebase-index.md index d03f96dc23..d5abd40862 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -961,7 +961,7 @@ due time, the note's id and that phone's endpoint in `alert_reminder_times`, nev Administrator source intake also records admission events in `document_admission_events`. -`documents`, `document_pages`, `document_images`, `document_chunks`, `document_embedding_fields`, `document_index_units`, `document_table_facts`, `document_labels`, `document_summaries`, `document_sections`, `document_memory_cards`, `document_index_quality`, `document_title_words`, `document_publication_approvals`, `document_corpus_access_state`, `document_corpus_access_snapshots`, `ingestion_jobs`, `ingestion_job_stages`, `indexing_v3_agent_jobs`, `import_batches`, `image_caption_cache`, `rag_queries`, `rag_query_misses`, `rag_aliases`, `rag_response_cache`, `rag_retrieval_logs`, `rag_visual_eval_cases`, `rag_visual_eval_runs`, `rag_answer_feedback`, `clinical_registry_records`, `clinical_registry_record_sources`, `clinical_quality_feedback_triage`, `clinical_quality_feedback_triage_events`, `medication_records`, `differential_records`, `source_review_events`, `user_favourites`, `user_favourite_sets`, `user_preferences`, `work_admin_paperwork`, `work_backups`, `work_booking_courses`, `work_course_bookings`, `work_hospitals`, `work_hospital_teams`, `work_role_grants`, `alert_reminder_times`, `alert_brief_sent`, `api_rate_limits`, `api_rate_limit_subjects`, `audit_logs`, `storage_cleanup_jobs`, `on_call_entries`, `cme_years`, `cme_requirements`, `cme_routines`, `cme_entries`, `cme_allocations`, `cme_evidence`, `cme_year_snapshots`, `cme_year_amendments`, `cme_plan_goals`, `cme_plan_goal_carries`, `cme_entry_goals`, `cme_training_periods`, `cme_training_milestones`, `cme_missed_sessions`, `cme_entry_drafts`, `calendar_feed_tokens`, `on_call_shift_imports`, `on_call_shifts`, `on_call_services`, `on_call_service_sites`, `on_call_service_members`, `on_call_service_invitations`, `on_call_service_entries`, `on_call_service_reports`, `on_call_service_orientation`, `site_content_publications`, `site_content_reconciliation_plans`, `site_content_public_records`, `site_content_sync_state`, `site_content_sync_events`, `site_content_sync_event_plans`, `site_content_sync_worker_invocations`, `site_content_releases`, `site_content_release_records`, `site_content_release_receipts` +`documents`, `document_pages`, `document_images`, `document_chunks`, `document_embedding_fields`, `document_index_units`, `document_table_facts`, `document_evidence_revisions`, `document_labels`, `document_summaries`, `document_sections`, `document_memory_cards`, `document_index_quality`, `document_title_words`, `document_publication_approvals`, `document_corpus_access_state`, `document_corpus_access_snapshots`, `ingestion_jobs`, `ingestion_job_stages`, `indexing_v3_agent_jobs`, `import_batches`, `image_caption_cache`, `rag_queries`, `rag_query_misses`, `rag_aliases`, `rag_response_cache`, `rag_retrieval_logs`, `rag_visual_eval_cases`, `rag_visual_eval_runs`, `rag_answer_feedback`, `clinical_registry_records`, `clinical_registry_record_sources`, `clinical_quality_feedback_triage`, `clinical_quality_feedback_triage_events`, `medication_records`, `differential_records`, `source_review_events`, `user_favourites`, `user_favourite_sets`, `user_preferences`, `work_admin_paperwork`, `work_backups`, `work_booking_courses`, `work_course_bookings`, `work_hospitals`, `work_hospital_teams`, `work_role_grants`, `alert_reminder_times`, `alert_brief_sent`, `api_rate_limits`, `api_rate_limit_subjects`, `audit_logs`, `storage_cleanup_jobs`, `on_call_entries`, `cme_years`, `cme_requirements`, `cme_routines`, `cme_entries`, `cme_allocations`, `cme_evidence`, `cme_year_snapshots`, `cme_year_amendments`, `cme_plan_goals`, `cme_plan_goal_carries`, `cme_entry_goals`, `cme_training_periods`, `cme_training_milestones`, `cme_missed_sessions`, `cme_entry_drafts`, `calendar_feed_tokens`, `on_call_shift_imports`, `on_call_shifts`, `on_call_services`, `on_call_service_sites`, `on_call_service_members`, `on_call_service_invitations`, `on_call_service_entries`, `on_call_service_reports`, `on_call_service_orientation`, `site_content_publications`, `site_content_reconciliation_plans`, `site_content_public_records`, `site_content_sync_state`, `site_content_sync_events`, `site_content_sync_event_plans`, `site_content_sync_worker_invocations`, `site_content_releases`, `site_content_release_records`, `site_content_release_receipts` Team modes (Roster, Admin, Teaching): `admin_leave_balances`, `admin_settings`, `extra_time_records`, `on_call_service_member_events`, `registrar_assessments`, `registrar_training_milestones`, `roster_assignments`, `roster_calendar_links`, `roster_change_agreements`, `roster_changes`, `roster_draft_assignments`, `roster_drafts`, `roster_leave`, `roster_member_roles`, `roster_open_post_alerts`, `roster_open_post_keys`, `roster_open_shifts`, `roster_publication_protections`, `roster_publication_seen`, `roster_publications`, `roster_rotation_preferences`, `roster_rotation_rounds`, `roster_shift_codes`, `roster_staffing_needs`, `roster_swaps`, `roster_team_settings`, `roster_unavailability`, `teaching_attendance`, `teaching_audit_events`, `teaching_calendar_optins`, `teaching_checkin_claims`, `teaching_collection_sections`, `teaching_collections`, `teaching_display_links`, `teaching_feedback_answers`, `teaching_feedback_replied`, `teaching_group_members`, `teaching_groups`, `teaching_member_roles`, `teaching_notice_reads`, `teaching_notices`, `teaching_occurrences`, `teaching_readiness`, `teaching_resource_saves`, `teaching_resources`, `teaching_series`, `teaching_supervision_entries`, `teaching_supervision_notes`, `teaching_supervision_pairings`, `teaching_team_settings`, `teaching_week_adds`, `web_push_subscriptions`. diff --git a/supabase/applied-migration-hashes.json b/supabase/applied-migration-hashes.json index cc0e669797..bdb8e7cc94 100644 --- a/supabase/applied-migration-hashes.json +++ b/supabase/applied-migration-hashes.json @@ -1,7 +1,7 @@ { "note": "sha256 of every migration this repository has shipped, with newlines normalised. An entry whose hash changes means an applied migration was edited, which cannot reach the live database. See scripts/check-migration-immutability.mjs.", "generator": "scripts/check-migration-immutability.mjs", - "sealed_at": "2026-10-18T08:41:00.862Z", + "sealed_at": "2026-10-18T09:59:47.045Z", "versions": { "20260527000000_bulk_ingestion.sql": "813e8e314064a6dcbaaa55e2765a8ce901d5783b304b4a7af7bb7417a07daf7c", "20260527001000_documents_policy_cleanup.sql": "559c26f3172bf75e545a41adef75538b2bf648ea7d53c8eb3d3ea7c244c65a48", @@ -324,6 +324,7 @@ "20261017175448_scope_ingestion_claim_atomically.sql": "af192755dd24aa8a6e6146013e1b7abd1b2954a53410c301dedfcb7576c88eba", "20261018053558_recheck_ingestion_claim_scope_after_job_lock.sql": "a427b8aacce70579ad26bd701adb65912a345ab78d54e5b34386bbd05d261b6c", "20261018062736_recheck_legacy_ingestion_claim_document_after_job_lock.sql": "39f08a91ed4c15a8aff0dff80341242604101886de873c5cadafa65e9b92e3c8", - "20261018082910_audit_durable_recovery_pairing_and_cache_fences.sql": "f0667fed9f0294c1f5ba40252d11ee66e6de07be340a1ec789e0503a1192f2ac" + "20261018082910_audit_durable_recovery_pairing_and_cache_fences.sql": "f0667fed9f0294c1f5ba40252d11ee66e6de07be340a1ec789e0503a1192f2ac", + "20261018095804_rag_cache_scope_evidence_revision.sql": "0a3c6845cda4d8feb22c54dbf176c7ece61da0333c4a75edb0664b3046a37994" } } diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index fb59a19c60..6c0aa4b3f7 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,8 +1,8 @@ { - "generated_at": "2026-10-18T06:37:18.983Z", + "generated_at": "2026-10-18T10:29:05.818Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83", - "schema_sha256": "ddba5299f794d7be628fe0e9a85a4f7c4c4218bd29854c9e209a25a3c24909e5", + "schema_sha256": "deeee8f86fe40c6e07c7ff56e5c97a85cab409112af961eda4403b9f4384dadf", "replay_seconds": 14, "snapshot": { "views": [ @@ -2979,6 +2979,34 @@ "rls_forced": false, "rls_enabled": true }, + { + "acl": [ + "postgres=arwdDxtm/postgres", + "service_role=arwdDxtm/postgres" + ], + "name": "document_evidence_revisions", + "columns": [ + { + "name": "document_id", + "type": "uuid", + "default": null, + "identity": "", + "not_null": true, + "generated": "" + }, + { + "name": "revision", + "type": "bigint", + "default": "0", + "identity": "", + "not_null": true, + "generated": "" + } + ], + "reloptions": null, + "rls_forced": false, + "rls_enabled": true + }, { "acl": [ "postgres=arwdDxtm/postgres", @@ -14709,6 +14737,12 @@ "table": "document_embedding_fields", "def_hash": "7224a35b50c37a7394210ca930e4e99f" }, + { + "def": "CREATE UNIQUE INDEX document_evidence_revisions_pkey ON public.document_evidence_revisions USING btree (document_id)", + "name": "document_evidence_revisions_pkey", + "table": "document_evidence_revisions", + "def_hash": "2615588a76c4e5926413aec57fd7d419" + }, { "def": "CREATE INDEX document_images_created_at_idx ON public.document_images USING btree (created_at)", "name": "document_images_created_at_idx", @@ -18188,6 +18222,21 @@ "name": "set_document_embedding_field_content_hash", "table": "document_embedding_fields" }, + { + "def": "CREATE TRIGGER document_images_evidence_delete AFTER DELETE ON public.document_images REFERENCING OLD TABLE AS old_rows FOR EACH STATEMENT EXECUTE FUNCTION public.document_evidence_rows_changed()", + "name": "document_images_evidence_delete", + "table": "document_images" + }, + { + "def": "CREATE TRIGGER document_images_evidence_insert AFTER INSERT ON public.document_images REFERENCING NEW TABLE AS new_rows FOR EACH STATEMENT EXECUTE FUNCTION public.document_evidence_rows_changed()", + "name": "document_images_evidence_insert", + "table": "document_images" + }, + { + "def": "CREATE TRIGGER document_images_evidence_update AFTER UPDATE ON public.document_images REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows FOR EACH STATEMENT EXECUTE FUNCTION public.document_evidence_rows_changed()", + "name": "document_images_evidence_update", + "table": "document_images" + }, { "def": "CREATE TRIGGER document_index_units_updated_at BEFORE UPDATE ON public.document_index_units FOR EACH ROW EXECUTE FUNCTION public.set_updated_at()", "name": "document_index_units_updated_at", @@ -18213,6 +18262,21 @@ "name": "document_sections_updated_at", "table": "document_sections" }, + { + "def": "CREATE TRIGGER document_table_facts_evidence_delete AFTER DELETE ON public.document_table_facts REFERENCING OLD TABLE AS old_rows FOR EACH STATEMENT EXECUTE FUNCTION public.document_evidence_rows_changed()", + "name": "document_table_facts_evidence_delete", + "table": "document_table_facts" + }, + { + "def": "CREATE TRIGGER document_table_facts_evidence_insert AFTER INSERT ON public.document_table_facts REFERENCING NEW TABLE AS new_rows FOR EACH STATEMENT EXECUTE FUNCTION public.document_evidence_rows_changed()", + "name": "document_table_facts_evidence_insert", + "table": "document_table_facts" + }, + { + "def": "CREATE TRIGGER document_table_facts_evidence_update AFTER UPDATE ON public.document_table_facts REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows FOR EACH STATEMENT EXECUTE FUNCTION public.document_evidence_rows_changed()", + "name": "document_table_facts_evidence_update", + "table": "document_table_facts" + }, { "def": "CREATE TRIGGER document_title_words_enforce_public_scope BEFORE INSERT OR UPDATE ON public.document_title_words FOR EACH ROW EXECUTE FUNCTION public.enforce_document_title_word_scope()", "name": "document_title_words_enforce_public_scope", @@ -18620,6 +18684,13 @@ "def_hash": "f02e0c8d223bb2621468b958df75f1f1", "signature": "public.bind_public_source_upload_authority(jsonb)" }, + { + "acl": [ + "postgres=X/postgres" + ], + "def_hash": "e91b4cce6223c04f8e34780d31eb6aa9", + "signature": "public.bump_document_evidence_revisions(uuid[])" + }, { "acl": [ "postgres=X/postgres", @@ -19086,6 +19157,13 @@ "def_hash": "37f32cc93a66d9ce45bdcecf263ace70", "signature": "public.detect_legacy_ivfflat_indexes()" }, + { + "acl": [ + "postgres=X/postgres" + ], + "def_hash": "58f5ef7258095fc953849fdb45a832d1", + "signature": "public.document_evidence_rows_changed()" + }, { "acl": [ "postgres=X/postgres", @@ -19724,7 +19802,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "6495e84c303f7bfb857743ff77c613ee", + "def_hash": "6e2e9461e611d13573b2e1aea26f0d5e", "signature": "public.rag_cache_scope_revision_v1(uuid,boolean,uuid[])" }, { @@ -22154,6 +22232,16 @@ "name": "document_embedding_fields_source_chunk_id_fkey", "table": "document_embedding_fields" }, + { + "def": "FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE CASCADE", + "name": "document_evidence_revisions_document_id_fkey", + "table": "document_evidence_revisions" + }, + { + "def": "PRIMARY KEY (document_id)", + "name": "document_evidence_revisions_pkey", + "table": "document_evidence_revisions" + }, { "def": "FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE CASCADE", "name": "document_images_document_id_fkey", diff --git a/supabase/migrations/20261018095804_rag_cache_scope_evidence_revision.sql b/supabase/migrations/20261018095804_rag_cache_scope_evidence_revision.sql new file mode 100644 index 0000000000..9ca9970047 --- /dev/null +++ b/supabase/migrations/20261018095804_rag_cache_scope_evidence_revision.sql @@ -0,0 +1,91 @@ +-- DRAFT. Merge only inside the owner's approved database-change window, and only after +-- 20261018082910_audit_durable_recovery_pairing_and_cache_fences.sql (it replaces that +-- migration's rag_cache_scope_revision_v1). Owner approval: 17:55 AWST 2026-10-18, relayed by Grok Bot. +set local lock_timeout = '5s'; +set local statement_timeout = '30s'; + +-- Cached answers and searches were keyed by a revision that hashed only document rows, so an +-- edit to a table fact or image (threshold, action, caption, relevance, searchability, ...) +-- left the revision unchanged and stale evidence could be served from cache. Each document now +-- carries an evidence counter, bumped in the writing transaction by statement-level triggers +-- whenever a retrieval-relevant fact or image row is inserted, deleted or changed. The scope +-- revision stays a single pass over documents plus one keyed join: no per-request scan of +-- facts or images. +create table public.document_evidence_revisions ( + document_id uuid primary key references public.documents(id) on delete cascade, + revision bigint not null default 0 +); +alter table public.document_evidence_revisions owner to postgres; +alter table public.document_evidence_revisions enable row level security; +revoke all on table public.document_evidence_revisions from public, anon, authenticated; +grant all on table public.document_evidence_revisions to service_role; + +-- Bumps once per touched document per statement. Documents removed in the same statement +-- (cascade from public.documents) are skipped; their rows leave the scope revision anyway. +create function public.bump_document_evidence_revisions(p_document_ids uuid[]) returns void +language sql security definer set search_path = '' as $$ + insert into public.document_evidence_revisions as r(document_id, revision) + select d.id, 1 + from public.documents d + where d.id = any(p_document_ids) + order by d.id + on conflict (document_id) do update set revision = r.revision + 1 +$$; +alter function public.bump_document_evidence_revisions(uuid[]) owner to postgres; +revoke all on function public.bump_document_evidence_revisions(uuid[]) from public, anon, authenticated; + +-- Retrieval-relevant means every stored column except created_at (and generated search_tsv, +-- which follows its sources). A no-op UPDATE does not bump the counter. +create function public.document_evidence_rows_changed() returns trigger +language plpgsql security definer set search_path = '' as $$ +begin + if tg_op = 'INSERT' then + perform public.bump_document_evidence_revisions(array(select distinct n.document_id from new_rows n)); + elsif tg_op = 'DELETE' then + perform public.bump_document_evidence_revisions(array(select distinct o.document_id from old_rows o)); + else + perform public.bump_document_evidence_revisions(array( + select distinct x.document_id from ( + select o.document_id as old_document_id, n.document_id as new_document_id + from old_rows o join new_rows n on n.id = o.id + where (pg_catalog.to_jsonb(o) - array['created_at','search_tsv']) + is distinct from (pg_catalog.to_jsonb(n) - array['created_at','search_tsv']) + ) changed + cross join lateral (values (changed.old_document_id), (changed.new_document_id)) x(document_id) + )); + end if; + return null; +end $$; +alter function public.document_evidence_rows_changed() owner to postgres; +revoke all on function public.document_evidence_rows_changed() from public, anon, authenticated; + +create trigger document_table_facts_evidence_insert after insert on public.document_table_facts + referencing new table as new_rows for each statement execute function public.document_evidence_rows_changed(); +create trigger document_table_facts_evidence_update after update on public.document_table_facts + referencing old table as old_rows new table as new_rows for each statement execute function public.document_evidence_rows_changed(); +create trigger document_table_facts_evidence_delete after delete on public.document_table_facts + referencing old table as old_rows for each statement execute function public.document_evidence_rows_changed(); +create trigger document_images_evidence_insert after insert on public.document_images + referencing new table as new_rows for each statement execute function public.document_evidence_rows_changed(); +create trigger document_images_evidence_update after update on public.document_images + referencing old table as old_rows new table as new_rows for each statement execute function public.document_evidence_rows_changed(); +create trigger document_images_evidence_delete after delete on public.document_images + referencing old table as old_rows for each statement execute function public.document_evidence_rows_changed(); + +-- Same signature, digest shape and grants; each document's row hash now also carries its +-- evidence counter. +create or replace function public.rag_cache_scope_revision_v1(p_owner_id uuid, p_include_public boolean, p_document_ids uuid[] default null) +returns text language sql stable security invoker set search_path = '' as $$ + select 'rag-cache-scope-v1:' || pg_catalog.md5(coalesce(pg_catalog.string_agg(row_hash, '' order by id),'')) + from ( + select d.id, pg_catalog.md5(pg_catalog.jsonb_build_array(d.id,d.owner_id,d.status,d.updated_at,d.metadata,d.index_generation_id, + coalesce(r.revision, 0))::text) as row_hash + from public.documents d + left join public.document_evidence_revisions r on r.document_id = d.id + where (d.owner_id = p_owner_id or (coalesce(p_include_public,false) and d.owner_id is null)) + and (coalesce(cardinality(p_document_ids),0) = 0 or d.id = any(p_document_ids)) + ) revision +$$; +alter function public.rag_cache_scope_revision_v1(uuid,boolean,uuid[]) owner to postgres; +revoke all on function public.rag_cache_scope_revision_v1(uuid,boolean,uuid[]) from public, anon, authenticated; +grant execute on function public.rag_cache_scope_revision_v1(uuid,boolean,uuid[]) to service_role; diff --git a/supabase/schema.sql b/supabase/schema.sql index 8e6db93a47..56a7525fb7 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -35492,3 +35492,96 @@ end $$; alter function public.teaching_depth_command(uuid,uuid,text,jsonb) owner to postgres; revoke all on function public.teaching_depth_command(uuid,uuid,text,jsonb) from public, anon, authenticated; grant execute on function public.teaching_depth_command(uuid,uuid,text,jsonb) to service_role; + +-- Projection: 20261018095804_rag_cache_scope_evidence_revision.sql +-- DRAFT. Merge only inside the owner's approved database-change window, and only after +-- 20261018082910_audit_durable_recovery_pairing_and_cache_fences.sql (it replaces that +-- migration's rag_cache_scope_revision_v1). Owner approval: 17:55 AWST 2026-10-18, relayed by Grok Bot. +set local lock_timeout = '5s'; +set local statement_timeout = '30s'; + +-- Cached answers and searches were keyed by a revision that hashed only document rows, so an +-- edit to a table fact or image (threshold, action, caption, relevance, searchability, ...) +-- left the revision unchanged and stale evidence could be served from cache. Each document now +-- carries an evidence counter, bumped in the writing transaction by statement-level triggers +-- whenever a retrieval-relevant fact or image row is inserted, deleted or changed. The scope +-- revision stays a single pass over documents plus one keyed join: no per-request scan of +-- facts or images. +create table public.document_evidence_revisions ( + document_id uuid primary key references public.documents(id) on delete cascade, + revision bigint not null default 0 +); +alter table public.document_evidence_revisions owner to postgres; +alter table public.document_evidence_revisions enable row level security; +revoke all on table public.document_evidence_revisions from public, anon, authenticated; +grant all on table public.document_evidence_revisions to service_role; + +-- Bumps once per touched document per statement. Documents removed in the same statement +-- (cascade from public.documents) are skipped; their rows leave the scope revision anyway. +create function public.bump_document_evidence_revisions(p_document_ids uuid[]) returns void +language sql security definer set search_path = '' as $$ + insert into public.document_evidence_revisions as r(document_id, revision) + select d.id, 1 + from public.documents d + where d.id = any(p_document_ids) + order by d.id + on conflict (document_id) do update set revision = r.revision + 1 +$$; +alter function public.bump_document_evidence_revisions(uuid[]) owner to postgres; +revoke all on function public.bump_document_evidence_revisions(uuid[]) from public, anon, authenticated; + +-- Retrieval-relevant means every stored column except created_at (and generated search_tsv, +-- which follows its sources). A no-op UPDATE does not bump the counter. +create function public.document_evidence_rows_changed() returns trigger +language plpgsql security definer set search_path = '' as $$ +begin + if tg_op = 'INSERT' then + perform public.bump_document_evidence_revisions(array(select distinct n.document_id from new_rows n)); + elsif tg_op = 'DELETE' then + perform public.bump_document_evidence_revisions(array(select distinct o.document_id from old_rows o)); + else + perform public.bump_document_evidence_revisions(array( + select distinct x.document_id from ( + select o.document_id as old_document_id, n.document_id as new_document_id + from old_rows o join new_rows n on n.id = o.id + where (pg_catalog.to_jsonb(o) - array['created_at','search_tsv']) + is distinct from (pg_catalog.to_jsonb(n) - array['created_at','search_tsv']) + ) changed + cross join lateral (values (changed.old_document_id), (changed.new_document_id)) x(document_id) + )); + end if; + return null; +end $$; +alter function public.document_evidence_rows_changed() owner to postgres; +revoke all on function public.document_evidence_rows_changed() from public, anon, authenticated; + +create trigger document_table_facts_evidence_insert after insert on public.document_table_facts + referencing new table as new_rows for each statement execute function public.document_evidence_rows_changed(); +create trigger document_table_facts_evidence_update after update on public.document_table_facts + referencing old table as old_rows new table as new_rows for each statement execute function public.document_evidence_rows_changed(); +create trigger document_table_facts_evidence_delete after delete on public.document_table_facts + referencing old table as old_rows for each statement execute function public.document_evidence_rows_changed(); +create trigger document_images_evidence_insert after insert on public.document_images + referencing new table as new_rows for each statement execute function public.document_evidence_rows_changed(); +create trigger document_images_evidence_update after update on public.document_images + referencing old table as old_rows new table as new_rows for each statement execute function public.document_evidence_rows_changed(); +create trigger document_images_evidence_delete after delete on public.document_images + referencing old table as old_rows for each statement execute function public.document_evidence_rows_changed(); + +-- Same signature, digest shape and grants; each document's row hash now also carries its +-- evidence counter. +create or replace function public.rag_cache_scope_revision_v1(p_owner_id uuid, p_include_public boolean, p_document_ids uuid[] default null) +returns text language sql stable security invoker set search_path = '' as $$ + select 'rag-cache-scope-v1:' || pg_catalog.md5(coalesce(pg_catalog.string_agg(row_hash, '' order by id),'')) + from ( + select d.id, pg_catalog.md5(pg_catalog.jsonb_build_array(d.id,d.owner_id,d.status,d.updated_at,d.metadata,d.index_generation_id, + coalesce(r.revision, 0))::text) as row_hash + from public.documents d + left join public.document_evidence_revisions r on r.document_id = d.id + where (d.owner_id = p_owner_id or (coalesce(p_include_public,false) and d.owner_id is null)) + and (coalesce(cardinality(p_document_ids),0) = 0 or d.id = any(p_document_ids)) + ) revision +$$; +alter function public.rag_cache_scope_revision_v1(uuid,boolean,uuid[]) owner to postgres; +revoke all on function public.rag_cache_scope_revision_v1(uuid,boolean,uuid[]) from public, anon, authenticated; +grant execute on function public.rag_cache_scope_revision_v1(uuid,boolean,uuid[]) to service_role; diff --git a/tests/rag-cache-governance-revocation.test.ts b/tests/rag-cache-governance-revocation.test.ts index a968663723..22208e4c2b 100644 --- a/tests/rag-cache-governance-revocation.test.ts +++ b/tests/rag-cache-governance-revocation.test.ts @@ -84,6 +84,9 @@ function installStorage() { owner_id: ownerId as string | null, status: "indexed", updated_at: "2026-10-16T00:00:00.000Z", + // Models public.document_evidence_revisions: bumped by the DB triggers when a table fact or + // image of this document changes (tests/sql/rag-cache-scope-evidence-revision.sql proves it). + evidence_revision: 0, metadata: { indexed_at: "2026-10-15T00:00:00.000Z", index_generation_id: "generation-1", @@ -264,6 +267,28 @@ describe("durable source-governance cache fence (F13)", () => { }, ); + it.each(["table fact threshold", "image caption"])( + "rejects retained local and shared entries after a %s edit that leaves the document row unchanged", + async () => { + const { cache, state, document } = await setup(); + await seedBoth(cache, state); + const start = await cache.cacheIndexingVersion(args, { forceRefresh: true }); + expect(await cache.getCachedAnswer(args, Date.now(), { indexingVersionAtRequestStart: start })).not.toBeNull(); + // Another replica edits evidence only: updated_at, status and metadata stay as they were. + document.evidence_revision += 1; + const after = await cache.cacheIndexingVersion(args, { forceRefresh: true }); + expect(after).not.toBe(start); + expect(await cache.getCachedAnswer(args, Date.now(), { indexingVersionAtRequestStart: start })).toBeNull(); + expect( + await cache.getCachedSearch(args, telemetry.query_class, [], { indexingVersionAtRequestStart: start }), + ).toBeNull(); + expect(await cache.getSharedCachedAnswer(args, Date.now(), { indexingVersionAtRequestStart: start })).toBeNull(); + expect( + await cache.getSharedCachedSearch(args, telemetry.query_class, [], { indexingVersionAtRequestStart: start }), + ).toMatchObject({ kind: "miss" }); + }, + ); + it("keeps ordinary current-source hits and keeps the fresh-metadata refusal control", async () => { const { cache, state } = await setup(); await seedBoth(cache, state); diff --git a/tests/sql/rag-cache-scope-evidence-revision.sql b/tests/sql/rag-cache-scope-evidence-revision.sql new file mode 100644 index 0000000000..424fe99bf8 --- /dev/null +++ b/tests/sql/rag-cache-scope-evidence-revision.sql @@ -0,0 +1,73 @@ +-- Disposable database only. Proves table-fact and image edits change the RAG cache scope revision. +\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.rev() returns text language sql as $$ + select public.rag_cache_scope_revision_v1('11111111-1111-4111-8111-111111111111'::uuid, false, null) +$$; +create function pg_temp.other_rev() returns text language sql as $$ + select public.rag_cache_scope_revision_v1('11111111-1111-4111-8111-111111111111'::uuid, false, + array['00000000-0000-4000-8000-0000000000e2']::uuid[]) +$$; +select pg_temp.assert_true(not exists (select 1 from public.documents), 'requires an empty disposable database'); +insert into auth.users(id) values ('11111111-1111-4111-8111-111111111111'); +insert into public.documents(id, owner_id, title, file_name, file_type, storage_path, status, metadata) +values + ('00000000-0000-4000-8000-0000000000e1', '11111111-1111-4111-8111-111111111111', + 'Synthetic evidence', 'fixture.pdf', 'application/pdf', 'synthetic/evidence-1', 'indexed', '{}'), + ('00000000-0000-4000-8000-0000000000e2', '11111111-1111-4111-8111-111111111111', + 'Synthetic other', 'other.pdf', 'application/pdf', 'synthetic/evidence-2', 'indexed', '{}'); + +create temp table seen(step text primary key, rev text not null, other text not null); +insert into seen values ('start', pg_temp.rev(), pg_temp.other_rev()); + +insert into public.document_table_facts(id, owner_id, document_id, table_title, clinical_parameter, threshold_value, action) +values ('00000000-0000-4000-8000-0000000000f1', '11111111-1111-4111-8111-111111111111', + '00000000-0000-4000-8000-0000000000e1', 'Synthetic table', 'synthetic level', '> 1', 'review'); +insert into seen values ('fact insert', pg_temp.rev(), pg_temp.other_rev()); +select pg_temp.assert_true((select rev from seen where step = 'fact insert') <> (select rev from seen where step = 'start'), + 'inserting a table fact must change the revision'); + +update public.document_table_facts set threshold_value = '> 2' where id = '00000000-0000-4000-8000-0000000000f1'; +insert into seen values ('fact threshold', pg_temp.rev(), pg_temp.other_rev()); +select pg_temp.assert_true((select rev from seen where step = 'fact threshold') <> (select rev from seen where step = 'fact insert'), + 'editing a fact threshold must change the revision'); + +update public.document_table_facts set action = action where id = '00000000-0000-4000-8000-0000000000f1'; +select pg_temp.assert_true(pg_temp.rev() = (select rev from seen where step = 'fact threshold'), + 'a no-op fact update must not change the revision'); + +insert into public.document_images(id, document_id, storage_path, caption) +values ('00000000-0000-4000-8000-0000000000a1', '00000000-0000-4000-8000-0000000000e1', 'synthetic/image-1.png', 'Synthetic caption'); +insert into seen values ('image insert', pg_temp.rev(), pg_temp.other_rev()); +select pg_temp.assert_true((select rev from seen where step = 'image insert') <> (select rev from seen where step = 'fact threshold'), + 'inserting an image must change the revision'); + +update public.document_images set caption = 'Edited synthetic caption' where id = '00000000-0000-4000-8000-0000000000a1'; +insert into seen values ('image caption', pg_temp.rev(), pg_temp.other_rev()); +select pg_temp.assert_true((select rev from seen where step = 'image caption') <> (select rev from seen where step = 'image insert'), + 'editing an image caption must change the revision'); + +update public.document_images set clinical_relevance_score = 0.9, searchable = false +where id = '00000000-0000-4000-8000-0000000000a1'; +insert into seen values ('image relevance', pg_temp.rev(), pg_temp.other_rev()); +select pg_temp.assert_true((select rev from seen where step = 'image relevance') <> (select rev from seen where step = 'image caption'), + 'editing image relevance or searchability must change the revision'); + +delete from public.document_table_facts where id = '00000000-0000-4000-8000-0000000000f1'; +insert into seen values ('fact delete', pg_temp.rev(), pg_temp.other_rev()); +select pg_temp.assert_true((select rev from seen where step = 'fact delete') <> (select rev from seen where step = 'image relevance'), + 'deleting a table fact must change the revision'); + +select pg_temp.assert_true((select count(distinct other) = 1 from seen), + 'evidence edits on one document must not change another document''s scoped revision'); + +-- Deleting the document cascades its facts and images; the evidence triggers must not block it. +delete from public.documents where id = '00000000-0000-4000-8000-0000000000e1'; +select pg_temp.assert_true(not exists (select 1 from public.document_evidence_revisions + where document_id = '00000000-0000-4000-8000-0000000000e1'), 'the evidence counter is removed with its document'); +rollback;