diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json
index 8f05ef5f7a..64be9a2cb6 100644
--- a/data/repo-awareness-snapshot.json
+++ b/data/repo-awareness-snapshot.json
@@ -3148,6 +3148,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",
@@ -3443,6 +3448,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",
@@ -4108,6 +4118,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",
@@ -5263,6 +5278,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",
@@ -5278,6 +5298,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",
@@ -5358,6 +5383,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 15335b130b..99e35ea2a3 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.
+ 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 (
+