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.

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

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

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

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

) : null} {message ?

{message}

: null} -
diff --git a/src/components/roster/roster-month-page.tsx b/src/components/roster/roster-month-page.tsx index a4093d9784..c5092895de 100644 --- a/src/components/roster/roster-month-page.tsx +++ b/src/components/roster/roster-month-page.tsx @@ -240,7 +240,7 @@ export function RosterMonthPage({ now: pinnedNow }: { readonly now?: Date } = {} const assignments = useRosterRead(sheetDate ? oneTeamId : null, "assignments", sheetRange); const links = useRosterLinks(); const settings = useRosterSettings(); - // Rotations (and later booked courses): empty for readers outside the rotation preferences preview. + // Successful sources remain visible even when another calendar source fails. const calendar = useWorkCalendarEntries(); const holidays = WA_PUBLIC_HOLIDAY_DATES; @@ -353,6 +353,21 @@ export function RosterMonthPage({ now: pinnedNow }: { readonly now?: Date } = {} <> {notice ? {notice} : null} {shifts.teamMessage ? {shifts.teamMessage} : null} + {( + [ + ["rotations", "Rotations"], + ["bookings", "Course bookings"], + ] as const + ).map(([id, label]) => + calendar.sources[id]?.status === "error" ? ( + + {label} could not be loaded. Your shifts and other loaded calendar entries are still shown.{" "} + calendar.retry(id)}> + Retry {label.toLowerCase()} + + + ) : null, + )} ); diff --git a/src/components/roster/roster-settings-page.tsx b/src/components/roster/roster-settings-page.tsx index e26dd1a371..699239d633 100644 --- a/src/components/roster/roster-settings-page.tsx +++ b/src/components/roster/roster-settings-page.tsx @@ -28,6 +28,7 @@ import { rosterOutlineButton, } from "./roster-list"; import { describeLinkFailure, useRosterLinks } from "./use-roster-links"; +import { useAccountRequestScope } from "@/components/use-account-request-scope"; import { useRosterSettings } from "./use-roster-settings"; import { useRosterShifts } from "./use-roster-shifts"; import { RosterPageHeader } from "./roster-ui"; @@ -60,6 +61,8 @@ const EVENING_BEFORE: ReminderLeadTime = "evening-before"; type DeleteState = "idle" | "pending" | "deleting" | "deleted"; export function RosterSettingsPage() { + const { scope, isCurrent } = useAccountRequestScope(); + const [owner, setOwner] = useState(scope); const shifts = useRosterShifts(); const links = useRosterLinks(); const settings = useRosterSettings(); @@ -72,6 +75,14 @@ export function RosterSettingsPage() { >(null); const [confirmBusy, setConfirmBusy] = useState(false); + if (owner !== scope) { + setOwner(scope); + setDeleteState("idle"); + setNotice(null); + setConfirm(null); + setConfirmBusy(false); + } + const reminderOn = preferences.reminders.types[SHIFTS_REMINDER]?.calendarAlert === EVENING_BEFORE; const calendarShifts = settings.settings.calendarShifts; @@ -85,12 +96,14 @@ export function RosterSettingsPage() { const { deleteAll, reload: reloadShifts } = shifts; const { reload: reloadLinks } = links; const sendDelete = useCallback(() => { + if (!isCurrent()) return; if (timer.current !== null) window.clearTimeout(timer.current); timer.current = null; setDeleteState("deleting"); // Committed once the undo window ends: `keepalive` lets the request outlive // the page if it is closed or left now. Only the pending window cancels. void deleteAll({ keepalive: true }).then((result) => { + if (!isCurrent()) return; // A delete that removed your own data but left some team requests is done, with a note. setDeleteState(result.ok ? "deleted" : "idle"); if (result.ok) { @@ -105,7 +118,7 @@ export function RosterSettingsPage() { text: `${result.message ?? "Your roster data couldn't be deleted."} Some of it may already be gone. This page now shows what is left.`, }); }); - }, [deleteAll, reloadShifts, reloadLinks]); + }, [deleteAll, reloadShifts, reloadLinks, isCurrent]); // Leaving the page during the 30 seconds, closed or navigated away from // inside the app, cancels the pending delete: nothing is sent. @@ -126,27 +139,32 @@ export function RosterSettingsPage() { window.clearTimeout(timer.current); timer.current = null; }, - [], + [scope], ); function startDelete() { + if (!isCurrent()) return; setNotice(null); setDeleteState("pending"); timer.current = window.setTimeout(sendDelete, ROSTER_DELETE_UNDO_MS); } function undoDelete() { + if (!isCurrent()) return; if (timer.current !== null) window.clearTimeout(timer.current); timer.current = null; setDeleteState("idle"); } async function toggleCalendarShifts() { + if (!isCurrent()) return; const failure = await settings.update({ calendarShifts: !calendarShifts }); + if (!isCurrent()) return; if (failure) setNotice({ tone: "warning", text: failure }); } function toggleReminder() { + if (!isCurrent()) return; setPreference( "reminders", updateReminderType(preferences.reminders, SHIFTS_REMINDER, { @@ -156,19 +174,24 @@ export function RosterSettingsPage() { } async function removeWorkplace(name: string) { + if (!isCurrent()) return; // Its calendar links and imported shifts go together, so no refresh brings it back; then its codes. const removed = await shifts.removeWorkplace(name); + if (!isCurrent()) return; if (removed) { setNotice({ tone: "warning", text: removed }); return; } void links.reload(); const failure = await settings.update({ codes: { [name]: null } }); + if (!isCurrent()) return; setNotice(failure ? { tone: "warning", text: failure } : { tone: "neutral", text: "Removed" }); } async function linkAction(action: Promise, done: string) { + if (!isCurrent()) return; const failure = await action; + if (!isCurrent()) return; setNotice(failure ? { tone: "warning", text: failure } : { tone: "neutral", text: done }); } @@ -192,10 +215,12 @@ export function RosterSettingsPage() { } async function confirmRemoval() { + if (!isCurrent()) return; if (!confirm) return; setConfirmBusy(true); if (confirm.kind === "workplace") await removeWorkplace(confirm.name); else await linkAction(links.remove(confirm.id), "Removed"); + if (!isCurrent()) return; setConfirmBusy(false); setConfirm(null); } diff --git a/src/components/roster/use-roster-links.ts b/src/components/roster/use-roster-links.ts index 717b0ab2eb..7f799a2b7b 100644 --- a/src/components/roster/use-roster-links.ts +++ b/src/components/roster/use-roster-links.ts @@ -2,6 +2,8 @@ import { useCallback, useEffect, useState } from "react"; +import { useAccountRequestScope } from "@/components/use-account-request-scope"; + import type { RosterCalendarLink, RosterCalendarLinkReason } from "@/lib/roster/calendar-links"; /** @@ -165,47 +167,72 @@ async function fetchLinks(signal?: AbortSignal): Promise { } export function useRosterLinks(): RosterLinksState { + const { scope, isCurrent } = useAccountRequestScope(); + const [owner, setOwner] = useState(scope); const [status, setStatus] = useState("loading"); const [links, setLinks] = useState([]); - const apply = useCallback((result: LoadedLinks) => { - if (result === "aborted") return; - if (result === "signed-out" || result === "error") { - setStatus(result); - return; - } - setLinks(result); - setStatus("ready"); - }, []); + if (owner !== scope) { + setOwner(scope); + setStatus("loading"); + setLinks([]); + } - const load = useCallback(async () => apply(await fetchLinks()), [apply]); + const apply = useCallback( + (result: LoadedLinks) => { + if (!isCurrent() || result === "aborted") return; + if (result === "signed-out" || result === "error") { + setStatus(result); + return; + } + setLinks(result); + setStatus("ready"); + }, + [isCurrent], + ); + + const load = useCallback(async () => { + if (isCurrent()) apply(await fetchLinks()); + }, [apply, isCurrent]); useEffect(() => { const controller = new AbortController(); - fetchLinks(controller.signal).then(apply, () => undefined); + fetchLinks(controller.signal).then( + (result) => { + if (!controller.signal.aborted) apply(result); + }, + () => undefined, + ); return () => controller.abort(); }, [apply]); /** Refresh one link. Null when it worked, or the sentence saying why not. */ - const refreshOne = useCallback(async (id: string): Promise => { - try { - const response = await fetch(`${LINKS_URL}/refresh`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id }), - }); - const payload = await response.json().catch(() => null); - if (!response.ok) return describeLinkFailure(errorCodeOf(payload)) ?? "Refresh did not work."; - const result = readRefreshResults(payload)?.find((item) => item.id === id); - if (!result) return "Refresh did not work."; - return result.ok ? null : (describeLinkFailure(result.reason ?? null) ?? "Refresh did not work."); - } catch { - return "That link could not be refreshed. Check your connection and try again."; - } - }, []); + const refreshOne = useCallback( + async (id: string): Promise => { + if (!isCurrent()) return null; + try { + const response = await fetch(`${LINKS_URL}/refresh`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id }), + }); + const payload = await response.json().catch(() => null); + if (!isCurrent()) return null; + if (!response.ok) return describeLinkFailure(errorCodeOf(payload)) ?? "Refresh did not work."; + const result = readRefreshResults(payload)?.find((item) => item.id === id); + if (!result) return "Refresh did not work."; + return result.ok ? null : (describeLinkFailure(result.reason ?? null) ?? "Refresh did not work."); + } catch { + if (!isCurrent()) return null; + return "That link could not be refreshed. Check your connection and try again."; + } + }, + [isCurrent], + ); const add = useCallback( async (url: string, workplace: string | null) => { + if (!isCurrent()) return null; try { const response = await fetch(LINKS_URL, { method: "POST", @@ -213,6 +240,7 @@ export function useRosterLinks(): RosterLinksState { body: JSON.stringify({ url, workplace }), }); const payload = await response.json().catch(() => null); + if (!isCurrent()) return null; if (!response.ok) { const code = errorCodeOf(payload); if (code === "duplicate_workplace") return "You already have a calendar link for that workplace."; @@ -225,34 +253,42 @@ export function useRosterLinks(): RosterLinksState { await load(); return null; } catch { + if (!isCurrent()) return null; return "That link could not be added. Check your connection and try again."; } }, - [load, refreshOne], + [load, refreshOne, isCurrent], ); - const remove = useCallback(async (id: string) => { - try { - const response = await fetch(LINKS_URL, { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id }), - }); - if (!response.ok) return "That link could not be removed."; - setLinks((current) => current.filter((link) => link.id !== id)); - return null; - } catch { - return "That link could not be removed. Check your connection and try again."; - } - }, []); + const remove = useCallback( + async (id: string) => { + if (!isCurrent()) return null; + try { + const response = await fetch(LINKS_URL, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id }), + }); + if (!isCurrent()) return null; + if (!response.ok) return "That link could not be removed."; + setLinks((current) => current.filter((link) => link.id !== id)); + return null; + } catch { + if (!isCurrent()) return null; + return "That link could not be removed. Check your connection and try again."; + } + }, + [isCurrent], + ); const refresh = useCallback( async (id: string) => { const failure = await refreshOne(id); + if (!isCurrent()) return null; await load(); return failure; }, - [load, refreshOne], + [load, refreshOne, isCurrent], ); return { status, links, add, remove, refresh, reload: load }; diff --git a/src/components/roster/use-roster-settings.ts b/src/components/roster/use-roster-settings.ts index e450ddecd4..4a4005f8a1 100644 --- a/src/components/roster/use-roster-settings.ts +++ b/src/components/roster/use-roster-settings.ts @@ -1,6 +1,8 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; + +import { useAccountRequestScope } from "@/components/use-account-request-scope"; import type { CodeMeaning } from "@/lib/roster/import/grid"; @@ -84,39 +86,55 @@ export function readRosterSettings(payload: unknown): RosterSettings | null { } export function useRosterSettings(): RosterSettingsState { + const { scope, isCurrent } = useAccountRequestScope(); + const [owner, setOwner] = useState(scope); const [status, setStatus] = useState("loading"); const [settings, setSettings] = useState(EMPTY_ROSTER_SETTINGS); const current = useRef(settings); + // Same-account writes remain serial; a new account starts an independent queue. + const queue = useRef>(Promise.resolve()); const accept = useCallback((next: RosterSettings) => { current.current = next; setSettings(next); }, []); + if (owner !== scope) { + setOwner(scope); + setSettings(EMPTY_ROSTER_SETTINGS); + setStatus("loading"); + } + useLayoutEffect(() => { + current.current = EMPTY_ROSTER_SETTINGS; + queue.current = Promise.resolve(); + }, [scope]); + useEffect(() => { const controller = new AbortController(); fetch(SETTINGS_URL, { cache: "no-store", signal: controller.signal }) .then(async (response) => { + if (controller.signal.aborted || !isCurrent()) return; if (response.status === 401) { setStatus("signed-out"); return; } if (!response.ok) throw new Error(`status ${response.status}`); - accept(readRosterSettings(await response.json().catch(() => null)) ?? EMPTY_ROSTER_SETTINGS); + const saved = readRosterSettings(await response.json().catch(() => null)); + if (controller.signal.aborted || !isCurrent()) return; + accept(saved ?? EMPTY_ROSTER_SETTINGS); setStatus("ready"); }) .catch((error: unknown) => { + if (controller.signal.aborted || !isCurrent()) return; if ((error as { name?: string })?.name === "AbortError") return; setStatus("error"); }); return () => controller.abort(); - }, [accept]); - - // Writes run one at a time so an older completion (or its rollback) can never overwrite a newer state. - const queue = useRef>(Promise.resolve()); + }, [accept, isCurrent]); const write = useCallback( async (patch: RosterSettingsPatch) => { + if (!isCurrent()) return null; const before = current.current; accept(applyRosterSettingsPatch(before, patch)); try { @@ -125,28 +143,32 @@ export function useRosterSettings(): RosterSettingsState { headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); + if (!isCurrent()) return null; if (!response.ok) { accept(before); return response.status === 401 ? "Sign in to change Roster settings." : "That setting could not be saved."; } const saved = readRosterSettings(await response.json().catch(() => null)); + if (!isCurrent()) return null; if (saved) accept(saved); return null; } catch { + if (!isCurrent()) return null; accept(before); return "That setting could not be saved. Check your connection and try again."; } }, - [accept], + [accept, isCurrent], ); const update = useCallback( (patch: RosterSettingsPatch) => { + if (!isCurrent()) return Promise.resolve(null); const next = queue.current.then(() => write(patch)); queue.current = next.catch(() => undefined); return next; }, - [write], + [write, isCurrent], ); return { status, settings, update }; diff --git a/src/components/roster/use-roster-shifts.ts b/src/components/roster/use-roster-shifts.ts index e180a352df..5cf35175c4 100644 --- a/src/components/roster/use-roster-shifts.ts +++ b/src/components/roster/use-roster-shifts.ts @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useState } from "react"; +import { useAccountRequestScope } from "@/components/use-account-request-scope"; import { useRosterTeams, rosterTeamUrl } from "./use-roster-team"; import { mergeMyShifts, type RosterDisplayShift } from "@/lib/roster/team/team-view"; import { rosterAssignmentsSchema, type RosterAssignment, type RosterTeam } from "@/lib/roster/team/model"; @@ -134,6 +135,8 @@ export function useRosterShifts( teamRange?: { from: string; to: string }, options?: { readonly ownHistory?: boolean; readonly skipWhenUnauthenticated?: boolean }, ): RosterShiftsState { + const { scope, isCurrent } = useAccountRequestScope(); + const [owner, setOwner] = useState(scope); const { zone } = useWorkTimeZone(); const skipGuest = Boolean(options?.skipWhenUnauthenticated && !hasSupabaseAuthSession()); const teams = useRosterTeams({ skip: skipGuest }); @@ -170,14 +173,14 @@ export function useRosterShifts( .then(async (response) => { if (!response.ok) throw new Error("older shifts unavailable"); const payload = await readPayload(response); - if (!controller.signal.aborted) + if (!controller.signal.aborted && isCurrent()) setHistory({ from, to: historyTo, shifts: payload.shifts ?? [], failed: false }); }) .catch(() => { - if (!controller.signal.aborted) setHistory({ from, to: historyTo, shifts: [], failed: true }); + if (!controller.signal.aborted && isCurrent()) setHistory({ from, to: historyTo, shifts: [], failed: true }); }); return () => controller.abort(); - }, [wantsHistory, from, historyTo, historyReload, options?.skipWhenUnauthenticated]); + }, [wantsHistory, from, historyTo, historyReload, options?.skipWhenUnauthenticated, isCurrent]); const [teamData, setTeamData] = useState<{ payload: typeof teams.data; owner: string; @@ -207,11 +210,11 @@ export function useRosterShifts( }), ) .then((rows) => { - if (!controller.signal.aborted) + if (!controller.signal.aborted && isCurrent()) setTeamData({ payload: teamPayload, owner: actorId, from, to, rows, message: null }); }) .catch(() => { - if (!controller.signal.aborted) + if (!controller.signal.aborted && isCurrent()) setTeamData({ payload: teamPayload, owner: actorId, @@ -222,7 +225,7 @@ export function useRosterShifts( }); }); return () => controller.abort(); - }, [actorId, teamPayload, from, to]); + }, [actorId, teamPayload, from, to, isCurrent]); const [status, setStatus] = useState(() => options?.skipWhenUnauthenticated && !hasSupabaseAuthSession() ? "signed-out" : "loading", ); @@ -231,40 +234,52 @@ export function useRosterShifts( const [demoMode, setDemoMode] = useState(false); const [sample, setSample] = useState(false); - const accept = useCallback((payload: Payload) => { - setShifts(payload.shifts ?? []); - setLatestImport(payload.latestImport ?? null); - setDemoMode(Boolean(payload.demoMode)); - setSample(Boolean(payload.sample)); - setStatus("ready"); - // Tells auto mode whether Roster has real shifts (the sample gate waits for it). - if (!payload.demoMode && !payload.sample) { - reportAreaData("rost", (payload.shifts?.length ?? 0) > 0 || payload.latestImport ? "has-data" : "empty"); - } - }, []); + const accept = useCallback( + (payload: Payload) => { + if (!isCurrent()) return; + setShifts(payload.shifts ?? []); + setLatestImport(payload.latestImport ?? null); + setDemoMode(Boolean(payload.demoMode)); + setSample(Boolean(payload.sample)); + setStatus("ready"); + // Tells auto mode whether Roster has real shifts (the sample gate waits for it). + if (!payload.demoMode && !payload.sample) { + reportAreaData("rost", (payload.shifts?.length ?? 0) > 0 || payload.latestImport ? "has-data" : "empty"); + } + }, + [isCurrent], + ); const apply = useCallback( (result: Loaded) => { - if (result === "aborted") return; + if (!isCurrent() || result === "aborted") return; if (result === "signed-out" || result === "error") setStatus(result); else accept(result); }, - [accept], + [accept, isCurrent], ); - const load = useCallback(async () => apply(await fetchShifts()), [apply]); + const load = useCallback(async () => { + if (isCurrent()) apply(await fetchShifts()); + }, [apply, isCurrent]); useEffect(() => { if (options?.skipWhenUnauthenticated && !hasSupabaseAuthSession()) { return; } const controller = new AbortController(); - fetchShifts(controller.signal).then(apply, () => undefined); + fetchShifts(controller.signal).then( + (result) => { + if (!controller.signal.aborted) apply(result); + }, + () => undefined, + ); return () => controller.abort(); }, [apply, options?.skipWhenUnauthenticated]); const save = useCallback( async (request: OnCallShiftImportRequest) => { + if (!isCurrent()) return null; try { const response = await fetch(ROSTER_SHIFTS_URL, { method: "POST", @@ -272,22 +287,37 @@ export function useRosterShifts( body: JSON.stringify(request), }); const payload = await readPayload(response); + if (!isCurrent()) return null; if (response.status === 401) return "Sign in to save your roster."; if (!response.ok) return errorText(payload, "Your roster could not be saved. Try again."); accept(payload); refreshHistory(); return null; } catch { + if (!isCurrent()) return null; return "Your roster could not be saved. Check your connection and try again."; } }, - [accept, refreshHistory], + [isCurrent, accept, refreshHistory], ); const [lastManualConflicts, setLastManualConflicts] = useState([]); + if (owner !== scope) { + setOwner(scope); + setStatus(skipGuest ? "signed-out" : "loading"); + setShifts([]); + setLatestImport(null); + setDemoMode(false); + setSample(false); + setHistory(null); + setTeamData(null); + setLastManualConflicts([]); + } + const addManual = useCallback( async (request: OnCallManualShiftRequest) => { + if (!isCurrent()) return null; try { const response = await fetch(`${ROSTER_SHIFTS_URL}/manual`, { method: "POST", @@ -295,61 +325,72 @@ export function useRosterShifts( body: JSON.stringify(request), }); const payload = await readPayload(response); + if (!isCurrent()) return null; if (response.status === 401) return "Sign in to add a shift."; if (!response.ok) return errorText(payload, "That shift could not be saved. Try again."); setLastManualConflicts(Array.isArray(payload.conflicts) ? payload.conflicts : []); await load(); + if (!isCurrent()) return null; refreshHistory(); return null; } catch { + if (!isCurrent()) return null; return "That shift could not be saved. Check your connection and try again."; } }, - [load, refreshHistory], + [isCurrent, load, refreshHistory], ); const removeSeries = useCallback( async (seriesId: string) => { + if (!isCurrent()) return null; try { const response = await fetch(`${ROSTER_SHIFTS_URL}/manual/${encodeURIComponent(seriesId)}`, { method: "DELETE", }); + if (!isCurrent()) return null; if (!response.ok) return errorText(await readPayload(response), "That shift could not be removed. Try again."); setShifts((current) => current.filter((shift) => shift.seriesId !== seriesId)); refreshHistory(); return null; } catch { + if (!isCurrent()) return null; return "That shift could not be removed. Check your connection and try again."; } }, - [refreshHistory], + [isCurrent, refreshHistory], ); const deleteAll = useCallback( async (options?: { keepalive?: boolean }) => { + if (!isCurrent()) return { ok: false, message: null }; try { const response = await fetch(ROSTER_SHIFTS_URL, { method: "DELETE", keepalive: options?.keepalive ?? false }); const payload = await readPayload(response); + if (!isCurrent()) return { ok: false, message: null }; if (!response.ok) return { ok: false, message: errorText(payload, "Your data could not be deleted. Try again.") }; accept(payload); refreshHistory(); return { ok: true, message: payload.message ?? null }; } catch { + if (!isCurrent()) return { ok: false, message: null }; return { ok: false, message: "Your data could not be deleted. Check your connection and try again." }; } }, - [accept, refreshHistory], + [isCurrent, accept, refreshHistory], ); const removeWorkplace = useCallback( async (workplace: string) => { + if (!isCurrent()) return null; try { const response = await fetch(ROSTER_WORKPLACES_URL, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ workplace }), }); + if (!isCurrent()) return null; if (!response.ok) { return errorText(await readPayload(response), "That workplace could not be removed. Try again."); } @@ -359,25 +400,28 @@ export function useRosterShifts( refreshHistory(); return null; } catch { + if (!isCurrent()) return null; return "That workplace could not be removed. Check your connection and try again."; } }, - [refreshHistory], + [isCurrent, refreshHistory], ); const dismissChanges = useCallback(async () => { + if (!isCurrent()) return; const current = latestImport; if (!current || current.seenAt) return; setLatestImport({ ...current, seenAt: new Date().toISOString() }); await fetch(`${ROSTER_SHIFTS_URL}/imports/${current.id}`, { method: "PATCH" }).catch(() => undefined); - }, [latestImport]); + }, [latestImport, isCurrent]); const reloadTeams = teams.reload; const reload = useCallback(async () => { + if (!isCurrent()) return; reloadTeams(); setHistoryReload((count) => count + 1); await load(); - }, [reloadTeams, load]); + }, [reloadTeams, load, isCurrent]); const sameTeams = teamData && diff --git a/src/components/roster/use-roster-team.ts b/src/components/roster/use-roster-team.ts index 56cacd1c85..c9c5a44963 100644 --- a/src/components/roster/use-roster-team.ts +++ b/src/components/roster/use-roster-team.ts @@ -11,6 +11,7 @@ import { type RosterRules, type RosterTeam, } from "@/lib/roster/team/model"; +import { useAccountRequestScope } from "@/components/use-account-request-scope"; import { useExampleData } from "@/lib/example-data/store"; import { setRosterHasEnabledTeam } from "@/lib/teaching/page-visibility"; import { sharedGet } from "@/lib/shared-get"; @@ -78,10 +79,13 @@ async function load(url: string, what: RosterReadWhat | "teams", signal: Abor } function useLoaded(url: string | null, what: RosterReadWhat | "teams"): RosterReadState { + const { scope, isCurrent } = useAccountRequestScope(); // Each answer remembers the address it was read from, so a new address shows // "loading" at once instead of the previous team's data. A reload of the same // address keeps the current answer on screen until the fresh one arrives. - const [state, setState] = useState<(Omit, "reload"> & { url: string }) | null>(null); + const [state, setState] = useState<(Omit, "reload"> & { url: string; scope: string }) | null>( + null, + ); const [generation, setGeneration] = useState(0); const reload = useCallback(() => setGeneration((value) => value + 1), []); @@ -89,17 +93,18 @@ function useLoaded(url: string | null, what: RosterReadWhat | "teams"): Roste if (!url) return; const controller = new AbortController(); void load(url, what, controller.signal).then((result) => { - if (result.status === "aborted" || controller.signal.aborted) return; + if (result.status === "aborted" || controller.signal.aborted || !isCurrent()) return; if (result.status === "ready") { - setState({ url, status: "ready", data: result.data, message: null, readAt: result.readAt }); + setState({ url, scope, status: "ready", data: result.data, message: null, readAt: result.readAt }); } else { - setState({ url, status: result.status, data: null, message: result.message, readAt: null }); + setState({ url, scope, status: result.status, data: null, message: result.message, readAt: null }); } }); return () => controller.abort(); - }, [url, what, generation]); + }, [url, what, generation, scope, isCurrent]); - if (!state || state.url !== url) return { status: "loading", data: null, message: null, readAt: null, reload }; + if (!state || state.url !== url || state.scope !== scope) + return { status: "loading", data: null, message: null, readAt: null, reload }; return { status: state.status, data: state.data, message: state.message, readAt: state.readAt, reload }; } diff --git a/src/components/teaching/organise-model.ts b/src/components/teaching/organise-model.ts index 713b21f043..460c730eed 100644 --- a/src/components/teaching/organise-model.ts +++ b/src/components/teaching/organise-model.ts @@ -12,6 +12,7 @@ import { } from "@/components/teaching/teaching-dates"; import type { LedgerGroup } from "@/components/teaching/teaching-modules"; import { withUnit } from "@/components/teaching/teaching-number"; +import { perthWallToIso } from "@/lib/roster/shifts/perth-time"; import { attendanceLabels, teachingCpdEntryHref, @@ -34,7 +35,7 @@ export type SeriesRow = ModelSeriesRow; export type { GroupRow }; export type OrganiseMember = z.infer["members"][number]; export type OrganiseRead = { series: SeriesRow[]; groups: GroupRow[]; members: OrganiseMember[] }; -export type Risk = { occurrenceId: string; rule: "room" | "clash"; text: string }; +export type Risk = { occurrenceId: string; rule: "room" | "clash" | "time"; text: string }; export type ChangeDraft = { status: "moved" | "cancelled"; date: string; @@ -149,12 +150,17 @@ export function draftFor(session: SessionSummary): ChangeDraft { }; } -/** Perth has no daylight saving, so +08:00 is exact. An unreadable date or time keeps the session's own. */ +/** Work-zone wall clock to instant. An unreadable or skipped time keeps the session's own. */ export function afterChange(session: SessionSummary, draft: ChangeDraft): SessionSummary { if (draft.status === "cancelled") return { ...session, status: "cancelled" }; - const start = Date.parse(`${draft.date}T${draft.startTime}:00+08:00`); - const startsAt = Number.isFinite(start) ? new Date(start).toISOString() : session.startsAt; - const endsAt = Number.isFinite(start) ? new Date(start + draft.minutes * 60_000).toISOString() : session.endsAt; + // Preserve the exact instant for a room-only edit, including an ambiguous DST clock time. + const sameClock = draft.date === perthDateKey(session.startsAt) && draft.startTime === perthTime(session.startsAt); + const converted = sameClock ? session.startsAt : perthWallToIso(draft.date, draft.startTime); + const startsAt = converted ?? session.startsAt; + const endsAt = + !converted || (sameClock && draft.minutes === durationMinutes(session.startsAt, session.endsAt)) + ? session.endsAt + : new Date(Date.parse(converted) + draft.minutes * 60_000).toISOString(); return { ...session, status: "moved", startsAt, endsAt, venue: draft.venue.trim() || null }; } @@ -170,6 +176,10 @@ export function draftChangesSomething(session: SessionSummary, draft: ChangeDraf } export function changeRisks(session: SessionSummary, draft: ChangeDraft, others: readonly SessionSummary[]): Risk[] { + if (draft.status !== "cancelled" && !perthWallToIso(draft.date, draft.startTime)) + return [ + { occurrenceId: session.occurrenceId, rule: "time", text: "Choose a valid date and time in your work time zone" }, + ]; const after = afterChange(session, draft); return sessionRisks([after, ...others.filter((o) => o.occurrenceId !== session.occurrenceId)]).filter( (r) => r.occurrenceId === session.occurrenceId, diff --git a/src/components/use-account-request-scope.ts b/src/components/use-account-request-scope.ts new file mode 100644 index 0000000000..d923240fc9 --- /dev/null +++ b/src/components/use-account-request-scope.ts @@ -0,0 +1,34 @@ +"use client"; + +import { useCallback, useLayoutEffect, useRef } from "react"; + +import { useAuthSession } from "@/lib/supabase/client"; + +function useOptionalAuth() { + try { + return useAuthSession(); + } catch (error) { + if (error instanceof Error && error.message === "useAuthSession must be used within AuthProvider.") return null; + throw error; + } +} + +/** Capture the identity of a callback, including callbacks retained by queues or timers. */ +export function useAccountRequestScope() { + const auth = useOptionalAuth(); + const scope = auth ? `${auth.authEpoch}:${auth.status}:${auth.session?.user.id ?? ""}` : "standalone"; + const epoch = auth?.authEpoch; + const isAuthEpochCurrent = auth?.isAuthEpochCurrent; + const current = useRef(scope); + useLayoutEffect(() => { + current.current = scope; + return () => { + current.current = null; + }; + }, [scope]); + const isCurrent = useCallback( + () => current.current === scope && (epoch === undefined || !isAuthEpochCurrent || isAuthEpochCurrent(epoch)), + [epoch, isAuthEpochCurrent, scope], + ); + return { scope, isCurrent }; +} diff --git a/src/components/work-calendar/use-work-calendar-entries.ts b/src/components/work-calendar/use-work-calendar-entries.ts index c3c4f3dc49..fad5acdfd0 100644 --- a/src/components/work-calendar/use-work-calendar-entries.ts +++ b/src/components/work-calendar/use-work-calendar-entries.ts @@ -13,8 +13,8 @@ import { mergeEntries, type WorkCalendarEntry } from "@/lib/work-calendar/entrie * hides its screen, and then adds no entries. * * `status` is "loading" while any source is still loading and "ready" after. - * A source that failed or is not available yet adds nothing: the calendar - * views stay quiet about it, and the feature's own page says what went wrong. + * A source that failed or is not available yet adds nothing. Callers inspect + * `sources` to disclose failed reads alongside entries from successful sources. */ export type WorkCalendarStatus = "off" | "loading" | "ready"; @@ -23,8 +23,8 @@ export type WorkCalendarEntriesRead = { readonly entries: readonly WorkCalendarEntry[]; /** Each source's own read, by source id. */ readonly sources: Readonly>; - /** Reads every source that failed again. */ - readonly retry: () => void; + /** Reads a failed source again, or every failed source when no id is given. */ + readonly retry: (sourceId?: string) => void; }; export function useWorkCalendarEntries({ @@ -42,8 +42,10 @@ export function useWorkCalendarEntries({ useEffect(() => { latest.current = reads; }); - const retry = useCallback(() => { - for (const read of latest.current) if (read.status === "error") read.retry?.(); + const retry = useCallback((sourceId?: string) => { + latest.current.forEach((read, index) => { + if (read.status === "error" && (!sourceId || WORK_CALENDAR_SOURCES[index]!.id === sourceId)) read.retry?.(); + }); }, []); return useMemo(() => { const parsed = JSON.parse(signature) as WorkCalendarSourceRead[]; diff --git a/src/components/work-screens/admin/pay-page.tsx b/src/components/work-screens/admin/pay-page.tsx index 84fd6a9f3d..afe5b85557 100644 --- a/src/components/work-screens/admin/pay-page.tsx +++ b/src/components/work-screens/admin/pay-page.tsx @@ -54,7 +54,7 @@ import { import { formatRecordedDate } from "@/lib/admin/renewal-dates"; import { copyTextToClipboard } from "@/lib/copy-to-clipboard"; import { FATIGUE_RULE_SET } from "@/lib/roster/fatigue-rules-source"; -import { perthDateOf } from "@/lib/roster/shifts/perth-time"; +import { addDaysToDate, perthDateOf } from "@/lib/roster/shifts/perth-time"; import { ADMIN_WORK_SCREEN_HREFS } from "@/lib/work-screens/admin/hrefs"; import { guardExampleAction, isExampleRecord } from "@/lib/example-data/guards"; import { @@ -106,13 +106,19 @@ export function AdminPayPage({ now: pinned }: { now?: Date } = {}) { const [checking, setChecking] = useState(false); const [failed, setFailed] = useState(false); - const shifts = useRosterShifts(); const teams = useRosterTeams(); const enabledTeams = (Array.isArray(teams.data?.teams) ? teams.data.teams : []).filter((team) => team.enabled); const oneTeamId = enabledTeams.length === 1 ? enabledTeams[0]!.serviceId : null; const overview = useRosterRead(oneTeamId, "overview"); const anchor = overview.status === "ready" ? (overview.data?.settings?.payFortnightAnchor ?? null) : null; const windows = payWindows(today, anchor); + // The previous fortnight can precede the 21-day list. The dated own-history API + // reads UTC days, so include the day before its local start for early Australian + // shifts. Pay windows remain exact; summaries clip and the hook deduplicates IDs. + const shifts = useRosterShifts( + { from: addDaysToDate(windows[1].start, -1), to: windows[0].end }, + { ownHistory: true }, + ); const own = shifts.status === "ready" && !shifts.sample && !page.signedOut; const extraNow = useRosterExtraTime(shifts.shifts, now, windows[0], own); const extraBefore = useRosterExtraTime(shifts.shifts, now, windows[1], own); @@ -124,9 +130,13 @@ export function AdminPayPage({ now: pinned }: { now?: Date } = {}) { const roster: RosterRead = page.signedOut ? { kind: "none", why: "Signed in, your rostered hours fill in here from Roster." } - : shifts.status === "loading" || shifts.teamLoading || (oneTeamId !== null && overview.status === "loading") + : shifts.status === "loading" || + shifts.teamLoading || + shifts.teamRefreshing || + shifts.historyLoading || + (oneTeamId !== null && overview.status === "loading") ? { kind: "loading" } - : shifts.status === "error" || shifts.status === "signed-out" + : shifts.status === "error" || shifts.status === "signed-out" || shifts.historyFailed ? { kind: "failed", retry: () => void shifts.reload() } : shifts.sample && !shifts.demoMode ? { kind: "none", why: "Roster is showing example shifts. Add your own roster and your hours fill in here." } diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 31ac253789..e5f42cf603 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -8,104 +8,102 @@ import { captureRequestError } from "@/lib/observability/error-tracking"; export async function register() { // Single Sentry init path per runtime (sentry.*.config.ts). Do not also call // initializeErrorTracking() — a second Sentry.init() races privacy/sampling options. + // Keep every Node-only import inside this positive runtime guard: Next's Edge + // webpack compiler prunes this branch, but does not prune imports after a + // negative early return based only on unreachable execution flow. if (process.env.NEXT_RUNTIME === "nodejs") { await import("./sentry.server.config"); - } - if (process.env.NEXT_RUNTIME === "edge") { - await import("./sentry.edge.config"); - } + if (process.env.NODE_ENV !== "production") { + // Development and staging keep their fallbacks, but one of them is silent: without + // RAG_QUERY_HASH_SECRET answer feedback cannot work at all, and the UI's advice to + // "run the question again" can never fix it. Say so once, here, rather than leaving + // it to be discovered per rating (2026-09-02 audit, L44). + const { warnAnswerFeedbackDisabled } = await import("@/lib/env"); + warnAnswerFeedbackDisabled(); + return; + } - // Only the Node.js server runtime in production needs this gate. Development - // keeps its local/demo fallbacks, and the Edge runtime doesn't use the Node-only - // server configuration these checks validate. - if (process.env.NEXT_RUNTIME !== "nodejs") return; + // Playwright validates a real production build, but its runner must remain a + // provider-free demo. Permit that otherwise-invalid combination only for the + // runner's isolated output and inert loopback environment. A partial or + // externally-addressed configuration still falls through to the production + // guard below and fails closed. + if (process.env.PLAYWRIGHT_OFFLINE_MODE === "true") { + const isolatedOutput = /^\.next-playwright\/[a-z0-9-]+\/dist$/i.test(process.env.NEXT_DIST_DIR ?? ""); + const providerFree = + process.env.NEXT_PUBLIC_DEMO_MODE === "true" && + process.env.RAG_PROVIDER_MODE === "offline" && + process.env.NEXT_PUBLIC_SUPABASE_URL === "http://127.0.0.1:1" && + !process.env.SUPABASE_SERVICE_ROLE_KEY && + !process.env.OPENAI_API_KEY; + if (isolatedOutput && providerFree) return; + throw new Error("Refusing to start: invalid isolated Playwright offline environment."); + } - if (process.env.NODE_ENV !== "production") { - // Development and staging keep their fallbacks, but one of them is silent: without - // RAG_QUERY_HASH_SECRET answer feedback cannot work at all, and the UI's advice to - // "run the question again" can never fix it. Say so once, here, rather than leaving - // it to be discovered per rating (2026-09-02 audit, L44). - const { warnAnswerFeedbackDisabled } = await import("@/lib/env"); - warnAnswerFeedbackDisabled(); - return; - } + // Defense in depth: no-auth must never be active in a production build (even + // though isLocalNoAuthMode() already hard-guards on NODE_ENV). + if (process.env.LOCAL_NO_AUTH === "true" || process.env.NEXT_PUBLIC_LOCAL_NO_AUTH === "true") { + throw new Error("Refusing to start: local no-auth mode is enabled in a production build."); + } - // Playwright validates a real production build, but its runner must remain a - // provider-free demo. Permit that otherwise-invalid combination only for the - // runner's isolated output and inert loopback environment. A partial or - // externally-addressed configuration still falls through to the production - // guard below and fails closed. - if (process.env.PLAYWRIGHT_OFFLINE_MODE === "true") { - const isolatedOutput = /^\.next-playwright\/[a-z0-9-]+\/dist$/i.test(process.env.NEXT_DIST_DIR ?? ""); - const providerFree = - process.env.NEXT_PUBLIC_DEMO_MODE === "true" && - process.env.RAG_PROVIDER_MODE === "offline" && - process.env.NEXT_PUBLIC_SUPABASE_URL === "http://127.0.0.1:1" && - !process.env.SUPABASE_SERVICE_ROLE_KEY && - !process.env.OPENAI_API_KEY; - if (isolatedOutput && providerFree) return; - throw new Error("Refusing to start: invalid isolated Playwright offline environment."); - } + const { env, isDemoMode, requireOpenAIEnv, requireQueryHashSecret, requireSentryEnv, requireServerEnv } = + await import("@/lib/env"); - // Defense in depth: no-auth must never be active in a production build (even - // though isLocalNoAuthMode() already hard-guards on NODE_ENV). - if (process.env.LOCAL_NO_AUTH === "true" || process.env.NEXT_PUBLIC_LOCAL_NO_AUTH === "true") { - throw new Error("Refusing to start: local no-auth mode is enabled in a production build."); - } + // A clinical production server must run against real, configured backends — never + // in demo mode, which bypasses auth and serves canned content. + if (isDemoMode()) { + throw new Error( + "Refusing to start: demo mode is enabled in a production build. Unset NEXT_PUBLIC_DEMO_MODE and configure Supabase.", + ); + } - const { env, isDemoMode, requireOpenAIEnv, requireQueryHashSecret, requireSentryEnv, requireServerEnv } = - await import("@/lib/env"); + // Throw actionable errors now if the configured backend is missing or points at + // the wrong project, instead of failing per-request. Explicit offline deployments + // intentionally have no OpenAI credential; auto/openai modes still fail closed. + requireServerEnv(); + if (env.RAG_PROVIDER_MODE !== "offline") requireOpenAIEnv(); - // A clinical production server must run against real, configured backends — never - // in demo mode, which bypasses auth and serves canned content. - if (isDemoMode()) { - throw new Error( - "Refusing to start: demo mode is enabled in a production build. Unset NEXT_PUBLIC_DEMO_MODE and configure Supabase.", - ); - } + // A keyed HMAC secret must be present so clinical-query hashes written to the log + // tables are not reversible (PIA-2). Fail closed rather than degrade to weak SHA-256. + requireQueryHashSecret(); + // Runtime DSN consistency only. Sourcemap upload credentials are build-time + // and are gated in next.config.ts — not re-checked here. + requireSentryEnv(); - // Throw actionable errors now if the configured backend is missing or points at - // the wrong project, instead of failing per-request. Explicit offline deployments - // intentionally have no OpenAI credential; auto/openai modes still fail closed. - requireServerEnv(); - if (env.RAG_PROVIDER_MODE !== "offline") requireOpenAIEnv(); - - // A keyed HMAC secret must be present so clinical-query hashes written to the log - // tables are not reversible (PIA-2). Fail closed rather than degrade to weak SHA-256. - requireQueryHashSecret(); - // Runtime DSN consistency only. Sourcemap upload credentials are build-time - // and are gated in next.config.ts — not re-checked here. - requireSentryEnv(); - - // Warm rag_aliases so the first post-boot search skips the cold-cache DB RTT. - // Non-blocking: failures are logged via the catch handler below. - const { warmEnabledRagAliasCache } = await import("@/lib/rag/rag-retrieval-variants"); - warmEnabledRagAliasCache().catch((error) => { - console.warn("rag_aliases cache warmup failed; first request will retry.", { - message: error instanceof Error ? error.message : String(error), + // Warm rag_aliases so the first post-boot search skips the cold-cache DB RTT. + // Non-blocking: failures are logged via the catch handler below. + const { warmEnabledRagAliasCache } = await import("@/lib/rag/rag-retrieval-variants"); + warmEnabledRagAliasCache().catch((error) => { + console.warn("rag_aliases cache warmup failed; first request will retry.", { + message: error instanceof Error ? error.message : String(error), + }); }); - }); - // Warm the three catalogue kinds universal-search Promise.all's, serially, so an idle - // process's first federated search is not the cold connection that blows the 1200 ms - // budget (monitor #2919 / ledger #WFSMMT). Non-blocking; failures swallowed inside. - const { warmCanonicalCatalogueSearchCaches } = await import("@/lib/site-content/warm-catalogue-caches"); - void warmCanonicalCatalogueSearchCaches(); + // Warm the three catalogue kinds universal-search Promise.all's, serially, so an idle + // process's first federated search is not the cold connection that blows the 1200 ms + // budget (monitor #2919 / ledger #WFSMMT). Non-blocking; failures swallowed inside. + const { warmCanonicalCatalogueSearchCaches } = await import("@/lib/site-content/warm-catalogue-caches"); + void warmCanonicalCatalogueSearchCaches(); - // Build the setup status once at boot. Every page load asks for it, and the first build after a - // restart took ~9.6 s in production (0.6 s warm). The route keeps its cache on globalThis, so - // this fills the copy requests read. The host only needs to be non-loopback to pass the - // local-origin guard; nothing is fetched from it. Non-blocking; failures are swallowed. - const { GET: warmSetupStatus } = await import("@/app/api/setup-status/route"); - void warmSetupStatus(new Request("https://startup-warm.invalid/api/setup-status")).catch(() => undefined); + // Build the setup status once at boot. Every page load asks for it, and the first build after a + // restart took ~9.6 s in production (0.6 s warm). The route keeps its cache on globalThis, so + // this fills the copy requests read. The host only needs to be non-loopback to pass the + // local-origin guard; nothing is fetched from it. Non-blocking; failures are swallowed. + const { GET: warmSetupStatus } = await import("@/app/api/setup-status/route"); + void warmSetupStatus(new Request("https://startup-warm.invalid/api/setup-status")).catch(() => undefined); + + // My Day › Alerts: once a minute, send due Remind me notes and morning briefs (owner yes, 6 Oct + // 2026). Does nothing without phone-alert keys; every send is claimed in the database first, so + // several server instances never double-send. + const { startTimedAlertSender } = await import("@/lib/alerts/timed-sender"); + const { createAdminClient } = await import("@/lib/supabase/admin"); + startTimedAlertSender(createAdminClient); + } - // My Day › Alerts: once a minute, send due Remind me notes and morning briefs (owner yes, 6 Oct - // 2026). Does nothing without phone-alert keys; every send is claimed in the database first, so - // several server instances never double-send. - const { startTimedAlertSender } = await import("@/lib/alerts/timed-sender"); - const { createAdminClient } = await import("@/lib/supabase/admin"); - startTimedAlertSender(createAdminClient); + if (process.env.NEXT_RUNTIME === "edge") { + await import("./sentry.edge.config"); + } } export { captureRequestError as onRequestError }; diff --git a/src/lib/calculators/clinical-math/governance.json b/src/lib/calculators/clinical-math/governance.json new file mode 100644 index 0000000000..449de0e351 --- /dev/null +++ b/src/lib/calculators/clinical-math/governance.json @@ -0,0 +1,95 @@ +{ + "schemaVersion": 1, + "releaseStatus": "held", + "scope": "All bedside physiological outputs, including numerical results, dose suggestions, thresholds, and management guidance. Psychometric calculator fixtures are governed separately.", + "clinicalReview": { + "status": "pending", + "reviewer": null, + "reviewedOn": null, + "approvalEvidence": null + }, + "evidenceLimit": "Implementation inventory and engineering drift pins only. Source leads below are unverified implementation attributions, not reviewed primary-source correspondence, clinical validation, or release approval.", + "algorithms": [ + { + "id": "cockcroft-gault", + "implementation": "src/lib/calculators/clinical-math/renal.ts", + "exports": ["calculateCockcroftGault", "calculateIdealBodyWeightKg"], + "formula": "CrCl=(140-age)*weight*1.23/SCr for male or 1.04 for female (SCr umol/L); (140-age)*weight/(72*SCr)*(0.85 if female) for mg/dL. Devine IBW base 50/45.5 kg + 0.9*(heightCm-152.4); adjusted weight=IBW+0.4*(actual-IBW).", + "sourceLeads": [ + "Cockcroft-Gault and Devine formulas named in implementation; exact primary publications and claim correspondence not recorded.", + "AMH / Therapeutic Guidelines named in implementation; edition, page and correspondence not recorded." + ], + "reviewRequired": [ + "Unit conversion and sex coefficients", + "Adult population and invalid-input limits", + "Weight selection, rounding and application to renal drug dosing", + "Renal stages, warnings and lithium management note" + ] + }, + { + "id": "ckd-epi-2021", + "implementation": "src/lib/calculators/clinical-math/renal.ts", + "exports": ["calculateCkdEpi2021"], + "formula": "eGFR=142*min(SCr/kappa,1)^alpha*max(SCr/kappa,1)^-1.2*0.9938^age*(1.012 if female); kappa=0.7/0.9 and alpha=-0.241/-0.302 for female/male; SCr mg/dL, conversion umol/L /88.42.", + "sourceLeads": [ + "2021 CKD-EPI creatinine equation (race-free) named in implementation; exact primary publication and claim correspondence not recorded.", + "KDIGO / Australian guidelines named in implementation; edition and correspondence not recorded." + ], + "reviewRequired": [ + "Primary equation and conversion correspondence", + "Adults, indexing to 1.73m2 and exclusions", + "Stages and any dose-use implications" + ] + }, + { + "id": "qtc", + "implementation": "src/lib/calculators/clinical-math/qtc.ts", + "exports": ["calculateQtc"], + "formula": "RR seconds=60/HR; Bazett QTc=QT/sqrt(RR); Fridericia QTc=QT/cbrt(RR); Framingham QTc=QT+154*(1-RR); Hodges QTc=QT+1.75*(HR-60), QT/QTc milliseconds.", + "sourceLeads": [ + "Bazett, Fridericia, Framingham and Hodges equations named in implementation; primary papers, versions and correspondence not recorded." + ], + "reviewRequired": [ + "Primary formula sources and units", + "Heart-rate/sex selection, measurement population and invalid-input limits", + "Risk thresholds, kinetic cautions and medication/cardiology advice" + ] + }, + { + "id": "lithium-level", + "implementation": "src/lib/calculators/clinical-math/lithium.ts", + "exports": ["evaluateLithiumLevel"], + "formula": "Rule-based age/indication target ranges and level classifications; no independent clinical validation recorded.", + "sourceLeads": [ + "Therapeutic Guidelines, RANZCP monitoring standards and EXTRIP named in implementation; exact editions and claim correspondence not recorded." + ], + "reviewRequired": [ + "Target ranges, age and indication population", + "Sampling and steady-state restrictions", + "Toxicity thresholds, withholding/admission/dialysis and repeat-monitoring advice" + ] + }, + { + "id": "proportional-lithium-dose", + "implementation": "src/lib/calculators/clinical-math/lithium.ts", + "exports": ["validateLithiumDoseInput", "calculateProportionalLithiumDose"], + "formula": "Raw dose=current dose*(target level/current level); rounded dose=round(raw dose/increment)*increment. Additional input/ratio/dose bounds are implementation controls, not validated safe prescribing limits.", + "sourceLeads": [ + "Linear kinetics and Australian tablet increments asserted in implementation; dedicated primary source and prescribing validation not recorded." + ], + "reviewRequired": [ + "Dedicated dose-calculator clinical validation", + "Linear kinetics, sampling, steady state and patient exclusions", + "Tablet splitting/formulations, rounding, bounds and dose/monitoring advice" + ] + } + ], + "implementationPins": { + "src/lib/calculators/clinical-math/renal.ts": "51617e6db4aba04ce6ea89ad0830e21a5902d4a2be75b373821e05ae42cb42d7", + "src/lib/calculators/clinical-math/qtc.ts": "f34b5852a82759ef83fcd19863fb45ad62765953120c5d2a5ac490aede404bd4", + "src/lib/calculators/clinical-math/lithium.ts": "5319dedd327f3312c1ad0ada409a8f737c67e68a82199a76e032cdbbf37806ab", + "src/lib/calculators/clinical-math/types.ts": "2a3a846b4f5bb7a21ea915ebfa032c2eb95409d3c9ef34fa5fbc6a609c9004f3", + "src/lib/calculators/clinical-math/index.ts": "93ec21888c7292f2b4667ba85ccb48f5f8027f1617c3110285aa3d895e050a0d", + "src/components/calculators/clinical-math-panel.tsx": "62f5704c81b5d440ebf2662387f469c7dd92097277b668ee383a688718503efd" + } +} diff --git a/src/lib/cme/evidence-repository.ts b/src/lib/cme/evidence-repository.ts index f851d0d812..6d4408fdd0 100644 --- a/src/lib/cme/evidence-repository.ts +++ b/src/lib/cme/evidence-repository.ts @@ -78,8 +78,8 @@ export function evidenceFromRow(row: EvidenceRow) { /** * Removes a file at its owner's request. The database records when and why * (and blanks the stored file name, which may itself carry an identifier); - * the stored object is then deleted, or queued for cleanup if storage does - * not answer. Allowed in a closed year and on an archived entry: removal + * and queues the exact stored object for deletion in the same transaction. + * Immediate Storage removal is best effort; the durable janitor obligation remains. Allowed in a closed year and on an archived entry: removal * exists for privacy, not bookkeeping. */ export async function removeCmeEvidence( @@ -98,8 +98,8 @@ export async function removeCmeEvidence( .eq("id", evidenceId) .maybeSingle(); if (lookupError) throw new PublicApiError("Evidence could not be loaded. Try again.", 503); - if (!existing || existing.removed_at) throw new PublicApiError("Evidence not found.", 404); - const { data, error } = await client.rpc("cme_remove_evidence", { + if (!existing) throw new PublicApiError("Evidence not found.", 404); + const { data, error } = await client.rpc("cme_remove_evidence_v2", { p_owner_id: ownerId, p_evidence_id: evidenceId, p_reason: reason, @@ -110,13 +110,13 @@ export async function removeCmeEvidence( throw new PublicApiError("Say why the file is being removed (3 to 500 characters).", 400); throw new PublicApiError("The file could not be removed. Try again.", 503); } - // The record of removal is committed; the stored object goes next, or is - // queued for cleanup if storage does not answer. - if (!(await deleteOrQueueCmeEvidenceObject(client, ownerId, existing.storage_path))) - throw new PublicApiError( - "The file was removed from this activity, but its stored copy still needs deleting by an administrator.", - 503, - ); + // The RPC has already committed the cleanup obligation with the tombstone. A lost + // response, process exit or unavailable Storage must not strand the private object. + try { + await client.storage.from("cme-private-evidence").remove([existing.storage_path]); + } catch { + // The janitor retries the durable, owner-bound cleanup row. + } return evidenceFromRow(data as EvidenceRow); } diff --git a/src/lib/ingestion-recovery.ts b/src/lib/ingestion-recovery.ts index 7d31b53afd..6d9b6131f0 100644 --- a/src/lib/ingestion-recovery.ts +++ b/src/lib/ingestion-recovery.ts @@ -3,6 +3,8 @@ export type IngestionRecoveryJob = { document_id: string; status: string | null; locked_at?: string | null; + lease_token?: string | null; + lease_expires_at?: string | null; documents?: { status?: string | null; page_count?: number | null; @@ -24,7 +26,11 @@ function parseLockedAt(value: string | null | undefined) { } export function isStaleProcessingJob(job: IngestionRecoveryJob, now: Date, staleAfterMinutes: number) { - if (job.status !== "processing" || !job.locked_at) return false; + if (job.status !== "processing") return false; + if (job.lease_expires_at != null) { + const expiry = parseLockedAt(job.lease_expires_at); + return expiry !== null && expiry <= now.getTime(); + } const lockedAt = parseLockedAt(job.locked_at); if (lockedAt === null) return false; return lockedAt < now.getTime() - staleAfterMinutes * 60_000; @@ -32,13 +38,21 @@ export function isStaleProcessingJob(job: IngestionRecoveryJob, now: Date, stale export function isRecoverableProcessingJob(job: IngestionRecoveryJob, now: Date, staleAfterMinutes: number) { if (job.status !== "processing") return false; + if (job.lease_expires_at != null) { + const expiry = parseLockedAt(job.lease_expires_at); + return expiry !== null && expiry <= now.getTime(); + } const lockedAt = parseLockedAt(job.locked_at); if (lockedAt === null) return true; return lockedAt < now.getTime() - staleAfterMinutes * 60_000; } export function isFreshProcessingJob(job: IngestionRecoveryJob, now: Date, staleAfterMinutes: number) { - if (job.status !== "processing" || !job.locked_at) return false; + if (job.status !== "processing") return false; + if (job.lease_expires_at != null) { + const expiry = parseLockedAt(job.lease_expires_at); + return expiry === null || expiry > now.getTime(); + } const lockedAt = parseLockedAt(job.locked_at); if (lockedAt === null) return false; return lockedAt >= now.getTime() - staleAfterMinutes * 60_000; @@ -50,7 +64,6 @@ export function buildIngestionRecoveryPlan(args: { staleAfterMinutes: number; }) { const now = args.now ?? new Date(); - const resetDocuments = new Set(); const actions: IngestionRecoveryAction[] = []; const jobsByDocument = new Map(); @@ -95,8 +108,8 @@ export function buildIngestionRecoveryPlan(args: { for (const job of recoverableJobs) { if (job.id !== retryJob.id) actions.push({ action: "supersede", jobId: job.id, documentId }); } - resetDocuments.add(documentId); - actions.push({ action: "retry", jobId: retryJob.id, documentId, resetDocument: true }); + // Recovery preserves all generations; the next claim mints a fresh write generation. + actions.push({ action: "retry", jobId: retryJob.id, documentId, resetDocument: false }); } // Apply supersedes before retries. A retry flips a row to `pending`; if a redundant sibling for @@ -110,7 +123,8 @@ export function buildIngestionRecoveryPlan(args: { return { actions: orderedActions, - resetDocumentIds: Array.from(resetDocuments), + // Kept for callers that display the preview; recovery never deletes index artifacts. + resetDocumentIds: [] as string[], supersedeCount: orderedActions.filter((action) => action.action === "supersede").length, retryCount: orderedActions.filter((action) => action.action === "retry").length, }; @@ -125,6 +139,8 @@ export type IngestionRecoverySkipReason = export type IngestionRecoveryRowState = { jobStatus: string | null; jobLockedAt: string | null; + jobLeaseToken: string | null; + jobLeaseExpiresAt: string | null; documentStatus: string | null; documentOwnerId: string | null; }; @@ -153,9 +169,8 @@ function recoveryActionKey(action: IngestionRecoveryAction) { * `buildIngestionRecoveryPlan` runs against a point-in-time snapshot, and the operator then * spends unbounded wall-clock time reading the preview before confirming. In that window a job * can finish normally, a worker can re-claim it, or a document can become `indexed` — and the - * plan's retry branch is destructive (`reset_document_index` deletes every chunk, page, image and - * section for the document with no guard of its own). Applying a stale plan therefore destroys a - * freshly committed index or races a live worker. + * retrying a stale plan can steal a current worker's authority. The RPC revalidates this + * snapshot under the same authority/job locks as worker claims and preserves index artifacts. * * This re-derives the plan from the fresh rows and keeps only actions the queue still asks for, * carrying the observed row state forward so the caller can guard each write on it. @@ -192,6 +207,8 @@ export function reconcileIngestionRecoveryPlan(args: { expected: { jobStatus: job.status ?? null, jobLockedAt: job.locked_at ?? null, + jobLeaseToken: job.lease_token ?? null, + jobLeaseExpiresAt: job.lease_expires_at ?? null, documentStatus: job.documents?.status ?? null, documentOwnerId: job.documents?.owner_id ?? null, }, @@ -200,3 +217,36 @@ export function reconcileIngestionRecoveryPlan(args: { return { applicable, skipped }; } + +/** Shared by both operator entry points: every mutation uses the atomic SQL contract. */ +export async function applyIngestionRecoveryActions( + client: { + rpc( + name: "recover_ingestion_job", + args: { + p_job_id: string; + p_action: string; + p_expected: IngestionRecoveryRowState; + }, + ): PromiseLike<{ data: unknown; error: { message: string } | null }>; + }, + applicable: ReconciledIngestionRecoveryAction[], +) { + const results: Array<{ action: IngestionRecoveryAction; applied: boolean; reason: string | null }> = []; + for (const { action, expected } of applicable) { + const { data, error } = await client.rpc("recover_ingestion_job", { + p_job_id: action.jobId, + p_action: action.action, + p_expected: expected, + }); + if (error) throw new Error(error.message); + const outcome = data && typeof data === "object" && !Array.isArray(data) ? (data as Record) : null; + const applied = outcome?.ok === true; + results.push({ + action, + applied, + reason: applied ? null : typeof outcome?.reason === "string" ? outcome.reason : "invalid_recovery_response", + }); + } + return results; +} diff --git a/src/lib/logger.ts b/src/lib/logger.ts index a431ef0bd9..7e06b9bd31 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -16,7 +16,7 @@ const MAX_DEPTH = 5; // `answer_text` through. Over-redacting a `queryMode`-style label is the accepted // cost of a redaction layer that does not depend on call-site discipline. const SENSITIVE_KEY = - /authorization|cookie|token|secret|api[-_]?key|password|service[-_]?role|email|query|question|prompt|\bcontent\b|answer|patient|\bmrn\b/i; + /authorization|cookie|token|secret|api[-_]?key|password|service[-_]?role|email|query|question|prompt|\bcontent\b|answer|patient|\bmrn\b|file[-_]?name|storage[-_]?path|document[-_]?path/i; const SENSITIVE_VALUE_PATTERN = /\b(?:mrn|ur|unit\s*no\.?)\s*[:#]?\s*\d{6,10}\b|\b\d{3}\s\d{3}\s\d{4}\b/i; diff --git a/src/lib/observability/agent-monitoring.ts b/src/lib/observability/agent-monitoring.ts index dd8fb8852b..a75c003786 100644 --- a/src/lib/observability/agent-monitoring.ts +++ b/src/lib/observability/agent-monitoring.ts @@ -53,12 +53,17 @@ export function instrumentOpenAIClientForAgentMonitoring(clien * interaction UUID — never query text or any derived clinical identifier. */ export function setAgentConversationId(conversationId: string): void { + // Only UUID syntax crosses this boundary; callers must use the generated + // interaction UUID, never a patient/document identifier or clinical text. + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(conversationId)) return; if (!isSentryAgentMonitoringEnabled()) { return; } try { - Sentry.setConversationId(conversationId); + // The SDK's Node convenience export delegates to this public Scope API. + // Next's Edge entry omits that export but exposes the same isolation scope. + Sentry.getIsolationScope().setConversationId(conversationId); } catch { // Optional observability must never take down answer generation. } diff --git a/src/lib/open-shifts/mine.ts b/src/lib/open-shifts/mine.ts index 064be66fa4..e3f2f549be 100644 --- a/src/lib/open-shifts/mine.ts +++ b/src/lib/open-shifts/mine.ts @@ -1,7 +1,7 @@ import { fatigueWarnings, type FatigueShift } from "@/lib/roster/fatigue-rules"; import { FATIGUE_RULE_SET } from "@/lib/roster/fatigue-rules-source"; import { isWorkedKind } from "@/lib/roster/shift-kind"; -import { addDaysToDate, perthDateOf } from "@/lib/roster/shifts/perth-time"; +import { addDaysToDate, perthDateOf, perthWallToIso } from "@/lib/roster/shifts/perth-time"; import type { OpenShiftListing } from "./model"; @@ -55,7 +55,7 @@ function hoursInside(row: { startsAt: string; endsAt: string }, fromMs: number, return end > start ? (end - start) / HOUR_MS : 0; } -const perthMidnight = (date: string) => Date.parse(`${date}T00:00:00+08:00`); +const workMidnight = (date: string) => Date.parse(perthWallToIso(date, "00:00")!); /** * The busiest 14 consecutive days among the windows that start today or in @@ -87,8 +87,8 @@ export function hoursMeter( for (let offset = 0; offset < 14; offset += 1) { const from = addDaysToDate(today, offset); const to = addDaysToDate(from, 13); - const fromMs = perthMidnight(from); - const toMs = perthMidnight(addDaysToDate(to, 1)); + const fromMs = workMidnight(from); + const toMs = workMidnight(addDaysToDate(to, 1)); const sum = (rows: readonly { startsAt: string; endsAt: string }[]) => rows.reduce((total, row) => total + hoursInside(row, fromMs, toMs), 0); const r = sum(worked); diff --git a/src/lib/open-shifts/posted.ts b/src/lib/open-shifts/posted.ts index 0699933e32..f8bc6777c7 100644 --- a/src/lib/open-shifts/posted.ts +++ b/src/lib/open-shifts/posted.ts @@ -1,4 +1,4 @@ -import { addDaysToDate, perthDateOf } from "@/lib/roster/shifts/perth-time"; +import { addDaysToDate, perthDateOf, perthWallToIso } from "@/lib/roster/shifts/perth-time"; import type { RosterManageOpenShift } from "@/lib/roster/team/model"; type Row = Pick; @@ -17,7 +17,7 @@ const byStart = (a: Row, b: Row) => Date.parse(a.startsAt) - Date.parse(b.starts /** Upcoming posted shifts by what the poster has to do: decide, wait, or nothing. */ export function groupPosted(shifts: readonly T[], now: Date): PostedGroups { const upcoming = shifts.filter((row) => Date.parse(row.endsAt) > now.getTime()); - const weekEnd = Date.parse(`${addDaysToDate(perthDateOf(now), 7)}T00:00:00+08:00`); + const weekEnd = Date.parse(perthWallToIso(addDaysToDate(perthDateOf(now), 7), "00:00")!); return { hasRequest: upcoming.filter((row) => row.status === "claimed" || row.status === "reported").sort(byStart), open: upcoming.filter((row) => row.status === "open").sort(byStart), diff --git a/src/lib/privacy.ts b/src/lib/privacy.ts index 97ee993125..c7fef4c7e1 100644 --- a/src/lib/privacy.ts +++ b/src/lib/privacy.ts @@ -69,6 +69,60 @@ export function safeErrorLogDetails(error: unknown) { }; } +/** + * Diagnostics for upload failures and fatal alerts must not contain free text. + * A filename, person's name or provider response need not match a redaction + * pattern to be sensitive. Preserve only fixed error classes and known codes. + */ +export function contentFreeErrorDetails(error: unknown): { + errorType: string; + errorCode?: string; + status?: number; +} { + const errorTypes = new Set([ + "Error", + "TypeError", + "RangeError", + "SyntaxError", + "ReferenceError", + "URIError", + "EvalError", + "AbortError", + "TimeoutError", + "WorkerAbortError", + ]); + const errorCodes = new Set([ + "23502", + "23503", + "23505", + "42501", + "42703", + "42P01", + "57014", + "P0001", + "PGRST116", + "ECONNRESET", + "ECONNREFUSED", + "ETIMEDOUT", + ]); + try { + const record = error && typeof error === "object" ? (error as Record) : {}; + const errorType = typeof record.name === "string" && errorTypes.has(record.name) ? record.name : "UnknownError"; + const errorCode = typeof record.code === "string" && errorCodes.has(record.code) ? record.code : undefined; + const status = + typeof record.status === "number" && + Number.isInteger(record.status) && + record.status >= 100 && + record.status <= 599 + ? record.status + : undefined; + return { errorType, ...(errorCode ? { errorCode } : {}), ...(status ? { status } : {}) }; + } catch { + // Error-like objects can have throwing getters. Reporting must not prevent cleanup. + return { errorType: "UnknownError" }; + } +} + export function redactCaptionIdentifiers(value: string): string { const clinicalRangePattern = /^\d+(?:\.\d+)?\s*-\s*\d+(?:\.\d+)?(?:\s*[A-Za-zµ/%][\w/%.-]*)?$/i; return value diff --git a/src/lib/rag/rag-answer-coalescing.ts b/src/lib/rag/rag-answer-coalescing.ts new file mode 100644 index 0000000000..cdc9870e14 --- /dev/null +++ b/src/lib/rag/rag-answer-coalescing.ts @@ -0,0 +1,87 @@ +import { + answerInflight, + answerInflightCacheKey, + cloneAnswer, + withRagAnswerQueryPlanDiagnostics, +} from "@/lib/rag/rag-cache"; +import { + recordAnswerOrigination, + recordAnswerOriginationFinished, + recordCoalescedAnswerWaiter, +} from "@/lib/observability/answer-coalescing-metrics"; +import { awaitWithCallerSignal } from "@/lib/rag/rag-abort-signal"; +import { observeRagAnswer } from "@/lib/rag/rag-programme-telemetry"; +import type { RagObservationContext, SearchChunksArgs } from "@/lib/rag/rag-contracts"; +import type { RagAnswer } from "@/lib/types"; + +type CoalescingRequest = SearchChunksArgs & { + onProgress?: (event: { stage: "cached"; message: string; reason: string }) => void | Promise; + observationContext?: RagObservationContext; +}; + +/** Join only a current answer promise, or originate once without replacing another caller. */ +export async function answerWithCoalescing( + args: CoalescingRequest, + startedAt: number, + enabled: boolean, + originate: () => Promise, +): Promise { + let inflightKey = enabled ? await answerInflightCacheKey(args) : null; + let existing = inflightKey ? answerInflight.get(inflightKey) : undefined; + + while (existing) { + recordCoalescedAnswerWaiter(); + await args.onProgress?.({ + stage: "cached", + message: "Waiting for an identical cited answer request already in progress.", + reason: "answer_inflight_coalesced", + }); + try { + const sharedAnswer = await awaitWithCallerSignal(existing, args.signal); + const currentInflightKey = await answerInflightCacheKey(args); + if (currentInflightKey !== inflightKey) { + // Review committed while waiting (or freshness is unavailable). Originate + // once against current state; never deliver the old promise's evidence. + inflightKey = currentInflightKey; + break; + } + const answer = cloneAnswer(sharedAnswer); + answer.routingReason = answer.routingReason + ? `${answer.routingReason}; answer_inflight_coalesced` + : "answer_inflight_coalesced"; + answer.latencyTimings = { + ...answer.latencyTimings, + total_latency_ms: Date.now() - startedAt, + }; + return observeRagAnswer(answer, args.observationContext); + } catch { + if (args.signal?.aborted) { + throw args.signal.reason ?? new DOMException("The operation was aborted.", "AbortError"); + } + // An originating caller's abort/failure must not fail this connected waiter. + // Recheck first: another waiter may have installed a replacement while the + // rejected promise's microtasks drained. + const replacement = inflightKey ? answerInflight.get(inflightKey) : undefined; + if (replacement && replacement !== existing) { + existing = replacement; + continue; + } + break; + } + } + + // A stale waiter may reach a revision another caller already owns. Do not + // replace that caller's promise; this bounded fallback originates independently. + const originationKey = inflightKey && !answerInflight.has(inflightKey) ? inflightKey : null; + if (originationKey) recordAnswerOrigination(); + const pending = originate() + .then((answer) => withRagAnswerQueryPlanDiagnostics(answer, args)) + .finally(() => { + if (originationKey) { + if (answerInflight.get(originationKey) === pending) answerInflight.delete(originationKey); + recordAnswerOriginationFinished(); + } + }); + if (originationKey) answerInflight.set(originationKey, pending); + return observeRagAnswer(await pending, args.observationContext); +} diff --git a/src/lib/rag/rag-cache.ts b/src/lib/rag/rag-cache.ts index 5869430730..3aba92b193 100644 --- a/src/lib/rag/rag-cache.ts +++ b/src/lib/rag/rag-cache.ts @@ -5,7 +5,6 @@ import { sanitizeRagEvalDiagnostics } from "@/lib/rag/rag-eval-diagnostics"; import { createHash } from "node:crypto"; import { createAdminClient } from "@/lib/supabase/admin"; import { buildClinicalTextSearchQuery } from "@/lib/clinical-search"; -import { readExpiringCacheEntry, writeBoundedExpiringCacheEntry } from "@/lib/bounded-ttl-cache"; import { ragDeepMemoryVersion } from "@/lib/deep-memory"; import { env } from "@/lib/env"; import { hashQueryText, queryCacheKeyForStorage } from "@/lib/query-privacy"; @@ -15,7 +14,6 @@ import { retrievalAccessScopeForArgs, retrievalAccessScopeKey, type RetrievalAcc import { assertRagRequestContextIntegrity } from "@/lib/rag/rag-context-snapshot"; import { governedCorpusComponentState, sanitizeRagCandidateMatchCounts } from "@/lib/rag/rag-contracts"; import { sanitizeRagQueryPlanDiagnostics } from "@/lib/rag/rag-retrieval-variants"; -import { normalizeSourceMetadata } from "@/lib/source-metadata"; import { retrievalPlanForQueryClass, type RagRequestContext, @@ -52,32 +50,20 @@ const searchCache = new Map< // still writes unsafe review-fallback answers under v24. v25 re-issues the intentional // #ZK460W invalidation: rows written under v24 (and earlier) become unreachable. The bump // costs one cold cache and is the mechanism this constant exists for. -export const ragCacheDependencyVersion = "rag-cache-v25"; -const cacheIndexingVersionTtlMs = 5000; -const cacheIndexingVersionMaxEntries = 512; -const cacheIndexingVersionCache = new Map(); -/** - * The two corpus-staleness stamps that carry no information about the corpus. - * - * Audit L21: a PostgREST `error` on the `documents` stamp query used to be - * folded into INDEXING_STAMP_EMPTY_CORPUS, making a failed read indistinguishable - * from "nothing is indexed". Both constants are stable across requests, so two - * stamp failures bracketing a worker-side reindex (which cannot call this - * process's in-process invalidation) let a pre-reindex answer be served with - * `answer_cache_hit` — the exact staleness this stamp exists to prevent. An - * error now produces the distinct UNAVAILABLE stamp, and neither read nor write - * trusts it. - */ +// v26: governance-sensitive, complete-scope revisions replace the latest-indexed +// document stamp. Old rows cannot establish freshness in the new namespace. +export const ragCacheDependencyVersion = "rag-cache-v26"; +/** Failed revision reads never establish an empty corpus or permit cache reuse (L21). */ const INDEXING_STAMP_UNAVAILABLE = "index-stamp-unavailable"; -const INDEXING_STAMP_EMPTY_CORPUS = "no-indexed-documents"; +const SCOPE_REVISION_PATTERN = /^rag-cache-scope-v1:[0-9a-f]{32}$/; function isUnavailableIndexingStamp(indexingVersion: string) { return indexingVersion.endsWith(`:${INDEXING_STAMP_UNAVAILABLE}`); } /** - * Invalidation generations for deferred `setCachedAnswer` promotions. Review / - * table-fact mutations do not change the documents indexing stamp, so the - * mid-request staleness guard alone cannot catch them. Epochs are owner-scoped + * Invalidation generations additionally fence process-local reads/promotions + * against same-process mutations, including mutations outside documents. The + * database scope revision fences source reviews across replicas. Epochs are owner-scoped * so one tenant's invalidation cannot suppress another tenant's valid write; * a null/undefined ownerId bump (full clear) advances the global epoch. */ @@ -167,12 +153,6 @@ function normalizedCacheQuery(query: string) { return buildClinicalTextSearchQuery(query).toLowerCase().replace(/\s+/g, " ").trim(); } -function cacheIndexingVersionCacheKey( - args: Pick, -) { - return scopeKey(args); -} - function modeKey(args: Pick) { return args.queryMode ?? "auto"; } @@ -419,6 +399,15 @@ export function answerCacheLookupAllowedForRequest(args: AnswerCachePolicyArgs, ); } +/** In-flight reuse needs the same durable source revision as persisted reuse. */ +export async function answerInflightCacheKey(args: SearchChunksArgs): Promise { + if (!answerCoalescingAllowedForRequest(args)) return null; + const epoch = captureInvalidationEpoch(args.ownerId); + const revision = await cacheIndexingVersion(args, { forceRefresh: true }); + if (isUnavailableIndexingStamp(revision) || invalidationEpochChanged(args.ownerId, epoch)) return null; + return `${scopedAnswerCacheKey(args)}|revision:${revision}|epoch:${epoch.global}:${epoch.owner}`; +} + type RagPublicCacheKind = "search" | "answer"; type RagPublicCacheEvidence = Pick; export type RagPublicCacheWriteProof = Readonly<{ @@ -682,13 +671,18 @@ export async function getCachedAnswer( answerCache.delete(key); return null; } - const indexingVersion = options?.indexingVersionAtRequestStart ?? (await cacheIndexingVersion(args)); + const invalidationEpochAtStart = captureInvalidationEpoch(args.ownerId); + const indexingVersion = await cacheIndexingVersion(args, { forceRefresh: true }); + if (invalidationEpochChanged(args.ownerId, invalidationEpochAtStart)) return null; if (isUnavailableIndexingStamp(cached.indexingVersion)) { answerCache.delete(key); return null; } if (isUnavailableIndexingStamp(indexingVersion)) return null; - if (cached.indexingVersion !== indexingVersion) { + if ( + cached.indexingVersion !== indexingVersion || + (options?.indexingVersionAtRequestStart && options.indexingVersionAtRequestStart !== indexingVersion) + ) { answerCache.delete(key); return null; } @@ -714,7 +708,7 @@ export async function getCachedAnswer( * Callers may deliberately NOT await this — it is a cache write, never part of the * response contract. `answerQuestionWithScopeUncoalesced` defers it on a shared-cache * hit precisely so the fastest path in the system does not pay this function's - * `documents` round trip before responding (latency audit 2026-07-28, L1-1). Deferring + * scope-revision round trip before responding (latency audit 2026-07-28, L1-1). Deferring * is safe only while nothing mutates `answer` after the call: the clone below happens * after an `await`, so a caller that mutates it in the meantime would cache the mutation. * @@ -725,7 +719,7 @@ export async function getCachedAnswer( * * Deferred callers must also survive `invalidateRagCachesForOwner` during the await: * the owner-scoped invalidation epoch checks below discard a post-invalidation write - * even when the documents indexing stamp is unchanged (table-fact / review mutations), + * even for mutations outside the document revision (for example table facts), * including after the shared-cache row commit so a late invalidation cannot leave a * stale shared answer behind. * @@ -992,14 +986,19 @@ export async function getCachedSearch( searchCache.delete(key); return null; } - const indexingVersion = options?.indexingVersionAtRequestStart ?? (await cacheIndexingVersion(args)); + const invalidationEpochAtStart = captureInvalidationEpoch(args.ownerId); + const indexingVersion = await cacheIndexingVersion(args, { forceRefresh: true }); throwIfAborted(args.signal); + if (invalidationEpochChanged(args.ownerId, invalidationEpochAtStart)) return null; if (isUnavailableIndexingStamp(cached.indexingVersion)) { searchCache.delete(key); return null; } if (isUnavailableIndexingStamp(indexingVersion)) return null; - if (cached.indexingVersion !== indexingVersion) { + if ( + cached.indexingVersion !== indexingVersion || + (options?.indexingVersionAtRequestStart && options.indexingVersionAtRequestStart !== indexingVersion) + ) { searchCache.delete(key); return null; } @@ -1164,69 +1163,39 @@ function isGenerationFallbackAnswer(answer: Pick, options?: { forceRefresh?: boolean }, ) { + // Legacy callers may request a refresh; all reads now use the same fresh RPC. + void options; throwIfAborted(args.signal); - const cacheKey = cacheIndexingVersionCacheKey(args); - if (options?.forceRefresh) cacheIndexingVersionCache.delete(cacheKey); - const cached = readExpiringCacheEntry(cacheIndexingVersionCache, cacheKey); - if (cached) return cached.value; - - let value = `${ragDeepMemoryVersion}:${INDEXING_STAMP_UNAVAILABLE}`; + const unavailable = `${ragDeepMemoryVersion}:${INDEXING_STAMP_UNAVAILABLE}`; try { - const supabase = createAdminClient(); - const documentFilters = args.documentIds?.length ? args.documentIds : args.documentId ? [args.documentId] : null; - let query = supabase - .from("documents") - .select("id,updated_at,metadata") - .eq("status", "indexed") - .order("updated_at", { ascending: false }) - .limit(1); const accessScope = retrievalAccessScopeForArgs(args); - if (accessScope.ownerId && accessScope.includePublic) { - query = query.or(`owner_id.eq.${accessScope.ownerId},owner_id.is.null`); - } else if (accessScope.ownerId) { - query = query.eq("owner_id", accessScope.ownerId); - } else { - query = query.is("owner_id", null); - } - if (documentFilters?.length) query = query.in("id", documentFilters); + const documentIds = args.documentIds?.length ? args.documentIds : args.documentId ? [args.documentId] : null; + let query = createAdminClient().rpc("rag_cache_scope_revision_v1", { + p_owner_id: accessScope.ownerId ?? null, + p_include_public: accessScope.includePublic, + p_document_ids: documentIds ? [...new Set(documentIds)].sort() : null, + }); if (args.signal) query = query.abortSignal(args.signal); const { data, error } = await query; throwIfAborted(args.signal); - if (error) { - // A failed read is NOT an empty corpus (audit L21). - value = `${ragDeepMemoryVersion}:${INDEXING_STAMP_UNAVAILABLE}`; - } else if (!data?.length) { - value = `${ragDeepMemoryVersion}:${INDEXING_STAMP_EMPTY_CORPUS}`; - } else { - const latest = data[0] as { id?: string; updated_at?: string | null; metadata?: unknown }; - const metadata = normalizeSourceMetadata(latest.metadata); - const indexedAt = metadata.indexed_at ?? latest.updated_at ?? "unknown"; - const generationId = - latest.metadata && typeof latest.metadata === "object" && "index_generation_id" in latest.metadata - ? String((latest.metadata as { index_generation_id?: unknown }).index_generation_id ?? "") - : ""; - value = `${ragDeepMemoryVersion}:${latest.id ?? "all"}:${indexedAt}:${generationId}`; - } + return !error && typeof data === "string" && data.length === 51 && SCOPE_REVISION_PATTERN.test(data) + ? `${ragDeepMemoryVersion}:${data}` + : unavailable; } catch { if (args.signal?.aborted) throw abortReason(args.signal); - value = `${ragDeepMemoryVersion}:${INDEXING_STAMP_UNAVAILABLE}`; - } - throwIfAborted(args.signal); - if (isUnavailableIndexingStamp(value)) { - cacheIndexingVersionCache.delete(cacheKey); - return value; + return unavailable; } - writeBoundedExpiringCacheEntry( - cacheIndexingVersionCache, - cacheKey, - { value, expiresAt: Date.now() + cacheIndexingVersionTtlMs }, - cacheIndexingVersionMaxEntries, - ); - return value; } export async function getSharedCachedSearch( @@ -1243,8 +1212,14 @@ export async function getSharedCachedSearch( if (args.ragQueryPlanMode === "shadow" && !isIssuedRagShadowControl(args)) return null; if (args.skipCache || env.RAG_SEARCH_CACHE_TTL_MS <= 0 || !isRagCacheAccessAllowed(args)) return null; const normalizedQuery = retrievalPlanCacheQuery(args, queryClass, queryVariants); - const indexingVersion = options?.indexingVersionAtRequestStart ?? (await cacheIndexingVersion(args)); + const invalidationEpochAtStart = captureInvalidationEpoch(args.ownerId); + const indexingVersion = await cacheIndexingVersion(args, { forceRefresh: true }); if (isUnavailableIndexingStamp(indexingVersion)) return { kind: "miss", reason: "cache_lookup_error" }; + if ( + invalidationEpochChanged(args.ownerId, invalidationEpochAtStart) || + (options?.indexingVersionAtRequestStart && options.indexingVersionAtRequestStart !== indexingVersion) + ) + return { kind: "miss", reason: "unknown_filter_miss" }; try { let query = sharedCacheSelector(createAdminClient(), "search", args, indexingVersion, normalizedQuery); if (args.signal) query = query.abortSignal(args.signal); @@ -1259,6 +1234,14 @@ export async function getSharedCachedSearch( if (!Array.isArray(payload.results)) { return { kind: "miss", reason: "cache_payload_invalid" }; } + // A review may commit while the shared row is being read. A late old write + // or failed deletion is harmless only when the row's revision is still current. + const revisionAfterRead = await cacheIndexingVersion(args, { forceRefresh: true }); + throwIfAborted(args.signal); + if (isUnavailableIndexingStamp(revisionAfterRead)) return { kind: "miss", reason: "cache_lookup_error" }; + if (indexingVersion !== revisionAfterRead || invalidationEpochChanged(args.ownerId, invalidationEpochAtStart)) { + return { kind: "miss", reason: "unknown_filter_miss" }; + } return { kind: "hit", results: cloneSearchResults(payload.results), @@ -1359,8 +1342,14 @@ export async function getSharedCachedAnswer( ) return null; try { - const indexingVersion = options?.indexingVersionAtRequestStart ?? (await cacheIndexingVersion(args)); + const invalidationEpochAtStart = captureInvalidationEpoch(args.ownerId); + const indexingVersion = await cacheIndexingVersion(args, { forceRefresh: true }); if (isUnavailableIndexingStamp(indexingVersion)) return null; + if ( + invalidationEpochChanged(args.ownerId, invalidationEpochAtStart) || + (options?.indexingVersionAtRequestStart && options.indexingVersionAtRequestStart !== indexingVersion) + ) + return null; const { data, error } = await sharedCacheSelector( createAdminClient(), "answer", @@ -1370,6 +1359,11 @@ export async function getSharedCachedAnswer( ).maybeSingle(); if (error || !data?.payload) return null; throwIfAborted(args.signal); + const revisionAfterRead = await cacheIndexingVersion(args, { forceRefresh: true }); + throwIfAborted(args.signal); + if (indexingVersion !== revisionAfterRead || invalidationEpochChanged(args.ownerId, invalidationEpochAtStart)) { + return null; + } const payload = data.payload as { answer: RagAnswer; queryPlanDiagnostics?: unknown }; const answer = markAnswerQueryPlanDiagnostics( cloneAnswer(payload.answer), @@ -1433,7 +1427,8 @@ async function replaceLegacySharedCacheRow( .eq("dependency_version", ragCacheDependencyVersion); deleteQuery = ownerId ? deleteQuery.eq("owner_id", ownerId) : deleteQuery.is("owner_id", null); if (args.signal) deleteQuery = deleteQuery.abortSignal(args.signal); - await deleteQuery; + const { error: deleteError } = await deleteQuery; + if (deleteError) return; if (args.signal?.aborted) return; let insertQuery = supabase.from("rag_response_cache").insert({ owner_id: ownerId ?? null, @@ -1473,7 +1468,8 @@ async function replaceSharedCacheRow( .eq("dependency_version", identity.dependencyVersion); deleteQuery = identity.ownerId ? deleteQuery.eq("owner_id", identity.ownerId) : deleteQuery.is("owner_id", null); if (signal) deleteQuery = deleteQuery.abortSignal(signal); - await deleteQuery; + const { error: deleteError } = await deleteQuery; + if (deleteError) return; if (signal?.aborted) return; let insertQuery = supabase.from("rag_response_cache").insert({ owner_id: identity.ownerId, @@ -1615,9 +1611,10 @@ async function deleteSharedCacheRowByIdentity(identity: SharedCacheRowIdentity) .eq("indexing_version", identity.indexingVersion) .eq("dependency_version", identity.dependencyVersion); deleteQuery = identity.ownerId ? deleteQuery.eq("owner_id", identity.ownerId) : deleteQuery.is("owner_id", null); - await deleteQuery; - } catch (error) { - console.warn("Shared answer cache post-invalidation cleanup failed:", error); + const { error } = await deleteQuery; + if (error) console.warn("Shared answer cache post-invalidation cleanup failed."); + } catch { + console.warn("Shared answer cache post-invalidation cleanup failed."); } } @@ -1651,10 +1648,11 @@ async function deleteSharedCachedAnswerRow( .eq("indexing_version", indexingVersion) .eq("dependency_version", ragCacheDependencyVersion); deleteQuery = args.ownerId ? deleteQuery.eq("owner_id", args.ownerId) : deleteQuery.is("owner_id", null); - await deleteQuery; - } catch (error) { + const { error } = await deleteQuery; + if (error) console.warn("Shared answer cache post-invalidation cleanup failed."); + } catch { // Shared cache cleanup after a raced invalidation is best effort. - console.warn("Shared answer cache post-invalidation cleanup failed:", error); + console.warn("Shared answer cache post-invalidation cleanup failed."); } } @@ -1666,13 +1664,16 @@ export function invalidateRagCachesForOwner(ownerId?: string | null) { answerCache.clear(); answerInflight.clear(); searchCache.clear(); - cacheIndexingVersionCache.clear(); void (async () => { try { - await createAdminClient().from("rag_response_cache").delete().in("cache_kind", ["search", "answer"]); - } catch (error) { + const { error } = await createAdminClient() + .from("rag_response_cache") + .delete() + .in("cache_kind", ["search", "answer"]); + if (error) console.warn("Shared cache invalidation failed (all kinds)."); + } catch { // Shared cache invalidation is best effort. - console.warn("Shared cache invalidation failed (all kinds):", error); + console.warn("Shared cache invalidation failed (all kinds)."); } })(); return; @@ -1693,19 +1694,17 @@ export function invalidateRagCachesForOwner(ownerId?: string | null) { if (ragCacheKeyMatchesOwner(key, ownerId) || key.includes(siteAwareAnswerOwnerToken(ownerId))) searchCache.delete(key); } - for (const key of cacheIndexingVersionCache.keys()) { - if (ragCacheKeyMatchesOwner(key, ownerId)) cacheIndexingVersionCache.delete(key); - } void (async () => { try { const deleteQuery = createAdminClient().from("rag_response_cache").delete(); const scopedQuery = sharedCacheOwnerId ? deleteQuery.eq("owner_id", sharedCacheOwnerId) : deleteQuery.is("owner_id", null); - await scopedQuery.in("cache_kind", ["search", "answer"]); - } catch (error) { + const { error } = await scopedQuery.in("cache_kind", ["search", "answer"]); + if (error) console.warn("Shared cache invalidation failed for owner."); + } catch { // Shared cache invalidation is best effort. - console.warn("Shared cache invalidation failed for owner:", error); + console.warn("Shared cache invalidation failed for owner."); } })(); } @@ -1713,14 +1712,15 @@ export function invalidateRagCachesForOwner(ownerId?: string | null) { function invalidateAnonymousSharedRagCaches() { void (async () => { try { - await createAdminClient() + const { error } = await createAdminClient() .from("rag_response_cache") .delete() .is("owner_id", null) .in("cache_kind", ["search", "answer"]); - } catch (error) { + if (error) console.warn("Shared cache invalidation failed for anonymous."); + } catch { // Shared cache invalidation is best effort. - console.warn("Shared cache invalidation failed for anonymous:", error); + console.warn("Shared cache invalidation failed for anonymous."); } })(); } diff --git a/src/lib/rag/rag.ts b/src/lib/rag/rag.ts index 482819452a..ae274646dc 100644 --- a/src/lib/rag/rag.ts +++ b/src/lib/rag/rag.ts @@ -167,20 +167,16 @@ import { answerCacheAllowedForSourcePolicyConflicts, answerCacheLookupAllowedForRequest, answerCoalescingAllowedForRequest, - answerInflight, cacheIndexingVersion, - cloneAnswer, getCachedAnswer, getCachedSearch, getSharedCachedAnswer, getSharedCachedSearch, isSearchCacheEnabled, isSearchCacheLookupEnabled, - scopedAnswerCacheKey, setCachedAnswer, setCachedSearch, restoreRagAnswerQueryPlanArgs, - withRagAnswerQueryPlanDiagnostics, } from "@/lib/rag/rag-cache"; export { invalidateRagCachesForDocumentMutation, @@ -198,11 +194,6 @@ import { } from "@/lib/rag/rag-context-pack"; export { packedContextCacheKey } from "@/lib/rag/rag-context-pack"; import { classifySearchCacheOutcome, recordCacheLookup } from "@/lib/observability/cache-metrics"; -import { - recordAnswerOrigination, - recordAnswerOriginationFinished, - recordCoalescedAnswerWaiter, -} from "@/lib/observability/answer-coalescing-metrics"; import { buildRagSourceBlock, neutralizeIdentityField, @@ -284,7 +275,7 @@ export { analyzeQueryWithClassifierFallback, resetClassifierVerdictMemoForTests, } from "@/lib/rag/rag-classifier-fallback"; -import { awaitWithCallerSignal } from "@/lib/rag/rag-abort-signal"; +import { answerWithCoalescing } from "@/lib/rag/rag-answer-coalescing"; import { hasAdmissionCommunityLookupIntent, hasAdmissionCommunityTitleSupport, @@ -2130,51 +2121,9 @@ export async function answerQuestionWithScope(args: AnswerQuestionWithScopeArgs) !isAnswerPilotBudgetActive() && !args.loadRegisteredPublishedOriginalSectionLayout && answerCoalescingAllowedForRequest(args); - const inflightKey = coalescingEnabled ? scopedAnswerCacheKey(args) : null; - let existing = inflightKey ? answerInflight.get(inflightKey) : undefined; - - while (existing) { - recordCoalescedAnswerWaiter(); - await args.onProgress?.({ - stage: "cached", - message: "Waiting for an identical cited answer request already in progress.", - reason: "answer_inflight_coalesced", - }); - try { - const answer = cloneAnswer(await awaitWithCallerSignal(existing, args.signal)); - answer.routingReason = answer.routingReason - ? `${answer.routingReason}; answer_inflight_coalesced` - : "answer_inflight_coalesced"; - answer.latencyTimings = { - ...answer.latencyTimings, - total_latency_ms: Date.now() - startedAt, - }; - return observeRagAnswer(answer, args.observationContext); - } catch { - throwIfAborted(args.signal); - // An originating caller's abort/failure must not fail this connected waiter. - // Recheck first: another waiter may have installed a replacement while the - // rejected promise's microtasks drained. - const replacement = inflightKey ? answerInflight.get(inflightKey) : undefined; - if (replacement && replacement !== existing) { - existing = replacement; - continue; - } - break; - } - } - - if (inflightKey) recordAnswerOrigination(); - const pending = answerQuestionWithScopeUncoalesced(args, startedAt) - .then((answer) => withRagAnswerQueryPlanDiagnostics(answer, args)) - .finally(() => { - if (inflightKey) { - answerInflight.delete(inflightKey); - recordAnswerOriginationFinished(); - } - }); - if (inflightKey) answerInflight.set(inflightKey, pending); - return observeRagAnswer(await pending, args.observationContext); + return answerWithCoalescing(args, startedAt, coalescingEnabled, () => + answerQuestionWithScopeUncoalesced(args, startedAt), + ); } async function answerQuestionWithScopeUncoalesced( args: AnswerQuestionWithScopeArgs, diff --git a/src/lib/site-content/adapters/index.ts b/src/lib/site-content/adapters/index.ts index 332e412078..9fb509a95f 100644 --- a/src/lib/site-content/adapters/index.ts +++ b/src/lib/site-content/adapters/index.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { calculators } from "@/lib/calculators/calculator-fixtures"; -import { factsheets } from "@/lib/factsheets-data"; +import { FACTSHEET_DEMO_NOTICE, factsheets } from "@/lib/factsheets-data"; import { THERAPY_CATALOGUE_ASSETS } from "@/data/therapy-catalogue-assets"; import type { Therapy } from "@/lib/therapy-compass-types"; import { dictionaryEntries, dictionarySource } from "@/lib/dictionary-data"; @@ -252,7 +252,13 @@ function buildFactsheetRecords() { domain: "factsheets", route: producer.routeBuilder(sheet.slug), title: sheet.title, - body: [sheet.brand, `Audience: ${sheet.audience}`, sheet.summary, `Review date: ${sheet.reviewedOn}`] + body: [ + sheet.brand, + `Audience: ${sheet.audience}`, + sheet.summary, + FACTSHEET_DEMO_NOTICE, + `Content month: ${sheet.reviewedOn}`, + ] .filter(Boolean) .join("\n"), sourceRole: "clinical_reference", @@ -261,6 +267,10 @@ function buildFactsheetRecords() { sourceStatus: "current", sourceLineage: [ ...repositoryLineage("src/components/factsheets/factsheets-data.ts", sheet), + ...repositoryLineage("src/lib/factsheets-data.ts:summary-context", { + notice: FACTSHEET_DEMO_NOTICE, + dateMeaning: "content-month", + }), ...sheet.sources.map((source) => ({ sourceId: `reference:${source.org}:${source.n}`, sourceHash: siteContentValueHash(source), diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts index d8a9a20d2a..9c01f9951a 100644 --- a/src/lib/supabase/database.types.ts +++ b/src/lib/supabase/database.types.ts @@ -2216,6 +2216,12 @@ export type Database = { enrichment_status: string; id: string; last_error: string | null; + lease_authority_digest: string | null; + lease_duration_seconds: number | null; + lease_expected_generation_id: string | null; + lease_expires_at: string | null; + lease_token: string | null; + lease_write_generation_id: string | null; locked_at: string | null; locked_by: string | null; max_attempts: number; @@ -2232,6 +2238,12 @@ export type Database = { enrichment_status?: string; id?: string; last_error?: string | null; + lease_authority_digest?: string | null; + lease_duration_seconds?: number | null; + lease_expected_generation_id?: string | null; + lease_expires_at?: string | null; + lease_token?: string | null; + lease_write_generation_id?: string | null; locked_at?: string | null; locked_by?: string | null; max_attempts?: number; @@ -2248,6 +2260,12 @@ export type Database = { enrichment_status?: string; id?: string; last_error?: string | null; + lease_authority_digest?: string | null; + lease_duration_seconds?: number | null; + lease_expected_generation_id?: string | null; + lease_expires_at?: string | null; + lease_token?: string | null; + lease_write_generation_id?: string | null; locked_at?: string | null; locked_by?: string | null; max_attempts?: number; @@ -2346,10 +2364,16 @@ export type Database = { document_id: string; error_message: string | null; id: string; + lease_authority_digest: string | null; + lease_duration_seconds: number | null; + lease_expected_generation_id: string | null; + lease_expires_at: string | null; + lease_token: string | null; + lease_write_generation_id: string | null; locked_at: string | null; locked_by: string | null; max_attempts: number; - next_run_at: string; + next_run_at: string | null; progress: number; stage: string; started_at: string | null; @@ -2364,10 +2388,16 @@ export type Database = { document_id: string; error_message?: string | null; id?: string; + lease_authority_digest?: string | null; + lease_duration_seconds?: number | null; + lease_expected_generation_id?: string | null; + lease_expires_at?: string | null; + lease_token?: string | null; + lease_write_generation_id?: string | null; locked_at?: string | null; locked_by?: string | null; max_attempts?: number; - next_run_at?: string; + next_run_at?: string | null; progress?: number; stage?: string; started_at?: string | null; @@ -2382,10 +2412,16 @@ export type Database = { document_id?: string; error_message?: string | null; id?: string; + lease_authority_digest?: string | null; + lease_duration_seconds?: number | null; + lease_expected_generation_id?: string | null; + lease_expires_at?: string | null; + lease_token?: string | null; + lease_write_generation_id?: string | null; locked_at?: string | null; locked_by?: string | null; max_attempts?: number; - next_run_at?: string; + next_run_at?: string | null; progress?: number; stage?: string; started_at?: string | null; @@ -3708,6 +3744,7 @@ export type Database = { storage_cleanup_jobs: { Row: { attempts: number; + cme_evidence_id: string | null; completed_at: string | null; created_at: string; document_bucket: string; @@ -3733,6 +3770,7 @@ export type Database = { }; Insert: { attempts?: number; + cme_evidence_id?: string | null; completed_at?: string | null; created_at?: string; document_bucket?: string; @@ -3758,6 +3796,7 @@ export type Database = { }; Update: { attempts?: number; + cme_evidence_id?: string | null; completed_at?: string | null; created_at?: string; document_bucket?: string; @@ -3782,6 +3821,13 @@ export type Database = { updated_at?: string; }; Relationships: [ + { + foreignKeyName: "storage_cleanup_jobs_cme_evidence_id_fkey"; + columns: ["cme_evidence_id"]; + isOneToOne: true; + referencedRelation: "cme_evidence"; + referencedColumns: ["id"]; + }, { foreignKeyName: "storage_cleanup_jobs_public_source_reservation_id_fkey"; columns: ["public_source_reservation_id"]; @@ -5033,6 +5079,22 @@ export type Database = { cme_set_entry_goal: { Args: { p_owner_id: string; p_entry_id: string; p_goal_id: string | null }; Returns: Json }; cme_set_entry_archived: { Args: { p_owner_id: string; p_entry_id: string; p_archived: boolean }; Returns: Json }; cme_evidence_counts: { Args: { p_owner_id: string; p_year: number }; Returns: Json }; + work_move_team_v1: { + Args: { p_actor_id: string; p_service_id: string; p_expected_hospital_id: string; p_to_hospital_id: string }; + Returns: string; + }; + recover_ingestion_job: { + Args: { p_job_id: string; p_action: string; p_expected: Json }; + Returns: Json; + }; + rag_cache_scope_revision_v1: { + Args: { p_owner_id: string | null; p_include_public: boolean; p_document_ids?: string[] | null }; + Returns: string; + }; + cme_remove_evidence_v2: { + Args: { p_owner_id: string; p_evidence_id: string; p_reason: string }; + Returns: Database["public"]["Tables"]["cme_evidence"]["Row"]; + }; cme_remove_evidence: { Args: { p_owner_id: string; p_evidence_id: string; p_reason: string }; Returns: Database["public"]["Tables"]["cme_evidence"]["Row"]; diff --git a/src/lib/trusted-document-admission.ts b/src/lib/trusted-document-admission.ts index 678829b33e..39efdd5c70 100644 --- a/src/lib/trusted-document-admission.ts +++ b/src/lib/trusted-document-admission.ts @@ -345,14 +345,22 @@ export async function uploadTrustedDocumentBytes( return Object.freeze({ storagePath: recoveryPath, collisionRecovered: true }); } +type SupersededObjectReleaseFailure = Readonly<{ + stage: "remove" | "queue" | "unknown"; + code: "provider_error" | "database_error" | "request_failed" | "unknown"; +}>; + type SupersededObjectRelease = Readonly<{ outcome: "skipped" | "removed" | "queued" | "unrecorded"; - failures: readonly string[]; + failures: readonly SupersededObjectReleaseFailure[]; }>; -function releaseFailureMessage(step: string, error: unknown): string { - const message = error instanceof Error ? error.message : isRecord(error) ? String(error.message ?? "") : ""; - return `${step}: ${message.slice(0, 200) || "unknown error"}`; +function releaseFailureCode(error: unknown): SupersededObjectReleaseFailure["code"] { + const code = isRecord(error) && typeof error.code === "string" ? error.code : ""; + if (["ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "AbortError"].includes(code)) return "request_failed"; + if (/^23|^42|^57|^PGRST/.test(code)) return "database_error"; + if (error instanceof Error) return "provider_error"; + return "unknown"; } /** @@ -374,20 +382,20 @@ export async function releaseSupersededDocumentObject( const supersededPath = assertTrustedDocumentStoragePath(args.ownerId, args.documentId, args.supersededPath); const committedPath = assertTrustedDocumentStoragePath(args.ownerId, args.documentId, args.committedPath); if (supersededPath === committedPath) return { outcome: "skipped", failures: [] }; - const failures: string[] = []; + const failures: SupersededObjectReleaseFailure[] = []; try { const { error } = await args.remove([supersededPath]); if (!error) return { outcome: "removed", failures }; - failures.push(releaseFailureMessage("remove", error)); + failures.push({ stage: "remove", code: releaseFailureCode(error) }); } catch (error) { - failures.push(releaseFailureMessage("remove", error)); + failures.push({ stage: "remove", code: releaseFailureCode(error) }); } try { const { error } = await args.queueCleanup(supersededPath); if (!error) return { outcome: "queued", failures }; - failures.push(releaseFailureMessage("queue", error)); + failures.push({ stage: "queue", code: releaseFailureCode(error) }); } catch (error) { - failures.push(releaseFailureMessage("queue", error)); + failures.push({ stage: "queue", code: releaseFailureCode(error) }); } return { outcome: "unrecorded", failures }; } diff --git a/src/lib/work-roles/people.ts b/src/lib/work-roles/people.ts index ad1431e215..13d54d9767 100644 --- a/src/lib/work-roles/people.ts +++ b/src/lib/work-roles/people.ts @@ -189,8 +189,7 @@ export async function readPeople( const roleRows = check(managers); const grantRows = check(grants); - // A whole-team supervisor whose team has moved away (the move could not remove them) stays filed - // under the hospital that gave the role, so name that team too. + // Legacy grants may remain filed under a hospital a team has left; name that team too. const serviceNames = new Map(serviceRows.map((row) => [row.id, row.name])); const elsewhere = [ ...new Set( @@ -434,7 +433,7 @@ function writeFailed(error: { code?: string | null }): PublicApiError { * grant is filed under the old hospital but `work_can` matches it by team alone, so it would keep * working at the new hospital where nobody there could see or remove it. Supervisors of named * trainees are left alone, because they are scoped to the old hospital and simply stop matching. - * There is no transaction, so the move goes first and a failed removal is reported, never hidden. + * The database trigger revokes whole-team supervisors in the same transaction as the move. * Answers with the hospital the team left. */ async function moveTeam( @@ -459,34 +458,22 @@ async function moveTeam( await client.from("work_hospitals").select("id").eq("id", change.toHospitalId).is("archived_at", null).limit(1), ); if (!target.length) throw hospitalUnavailable(); - // Only while it is still where it was read, so two moves at once cannot both land. - const { data, error } = await client - .from("work_hospital_teams") - .update({ hospital_id: change.toHospitalId, linked_by: context.userId, linked_at: new Date().toISOString() }) - .eq("service_id", change.serviceId) - .eq("hospital_id", from) - .select("service_id"); - if (error?.code === "23503") throw hospitalUnavailable(); - if (error) throw writeFailed(error); - if (!data?.length) { + // The versioned RPC requires atomic move/revocation SQL before any mutation. + const { data, error } = await client.rpc("work_move_team_v1", { + p_actor_id: context.userId, + p_service_id: change.serviceId, + p_expected_hospital_id: from, + p_to_hospital_id: change.toHospitalId, + }); + if (error?.message.includes("work_role_denied")) throw denied(); + if (error?.code === "23503" || error?.message.includes("work_hospital_unavailable")) throw hospitalUnavailable(); + if (error?.message.includes("work_team_changed")) { throw new PublicApiError("That team changed while you were moving it. Try again.", 409, { code: "work_team_changed", }); } - const { error: supervisorError } = await client - .from("work_role_grants") - .update({ revoked_at: new Date().toISOString(), revoked_by: context.userId }) - .eq("role", "supervisor") - .eq("service_id", change.serviceId) - .is("subject_user_id", null) - .is("revoked_at", null); - if (supervisorError) { - throw new PublicApiError( - "The team moved, but its whole-team supervisors weren't removed. Remove them under the hospital it left.", - 503, - { code: "work_team_supervisors_left" }, - ); - } + if (error) throw writeFailed(error); + if (data !== from) throw unavailable(); return from; } diff --git a/supabase/applied-migration-hashes.json b/supabase/applied-migration-hashes.json index 870bd6b62f..cc0e669797 100644 --- a/supabase/applied-migration-hashes.json +++ b/supabase/applied-migration-hashes.json @@ -1,7 +1,7 @@ { "note": "sha256 of every migration this repository has shipped, with newlines normalised. An entry whose hash changes means an applied migration was edited, which cannot reach the live database. See scripts/check-migration-immutability.mjs.", "generator": "scripts/check-migration-immutability.mjs", - "sealed_at": "2026-10-18T06:29:14.181Z", + "sealed_at": "2026-10-18T08:41:00.862Z", "versions": { "20260527000000_bulk_ingestion.sql": "813e8e314064a6dcbaaa55e2765a8ce901d5783b304b4a7af7bb7417a07daf7c", "20260527001000_documents_policy_cleanup.sql": "559c26f3172bf75e545a41adef75538b2bf648ea7d53c8eb3d3ea7c244c65a48", @@ -323,6 +323,7 @@ "20261017120000_teaching_display_revoke_null_creator_guard.sql": "3b92e22983206cbaa8950c07a6b3e6910b3701fa9c49a9f9e5504c232b4397e4", "20261017175448_scope_ingestion_claim_atomically.sql": "af192755dd24aa8a6e6146013e1b7abd1b2954a53410c301dedfcb7576c88eba", "20261018053558_recheck_ingestion_claim_scope_after_job_lock.sql": "a427b8aacce70579ad26bd701adb65912a345ab78d54e5b34386bbd05d261b6c", - "20261018062736_recheck_legacy_ingestion_claim_document_after_job_lock.sql": "39f08a91ed4c15a8aff0dff80341242604101886de873c5cadafa65e9b92e3c8" + "20261018062736_recheck_legacy_ingestion_claim_document_after_job_lock.sql": "39f08a91ed4c15a8aff0dff80341242604101886de873c5cadafa65e9b92e3c8", + "20261018082910_audit_durable_recovery_pairing_and_cache_fences.sql": "f0667fed9f0294c1f5ba40252d11ee66e6de07be340a1ec789e0503a1192f2ac" } } diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index 679b2eff60..fb59a19c60 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -2,7 +2,7 @@ "generated_at": "2026-10-18T06:37:18.983Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83", - "schema_sha256": "58e7d9c92b1264426f6cd239fdc136f978afe90770e31f950a0b4c505628e4fb", + "schema_sha256": "ddba5299f794d7be628fe0e9a85a4f7c4c4218bd29854c9e209a25a3c24909e5", "replay_seconds": 14, "snapshot": { "views": [ @@ -11458,6 +11458,14 @@ "not_null": true, "generated": "" }, + { + "name": "cme_evidence_id", + "type": "uuid", + "default": null, + "identity": "", + "not_null": false, + "generated": "" + }, { "name": "completed_at", "type": "timestamp with time zone", @@ -16441,6 +16449,12 @@ "table": "source_review_events", "def_hash": "616d06e4b7bca60c216c7c1a86d5e02f" }, + { + "def": "CREATE UNIQUE INDEX storage_cleanup_jobs_cme_evidence_key ON public.storage_cleanup_jobs USING btree (cme_evidence_id) WHERE (cme_evidence_id IS NOT NULL)", + "name": "storage_cleanup_jobs_cme_evidence_key", + "table": "storage_cleanup_jobs", + "def_hash": "285fe5104ce348207613e4bd62f0803d" + }, { "def": "CREATE INDEX storage_cleanup_jobs_document_idx ON public.storage_cleanup_jobs USING btree (document_id)", "name": "storage_cleanup_jobs_document_idx", @@ -18453,6 +18467,16 @@ "def": "CREATE TRIGGER work_course_bookings_updated_at BEFORE UPDATE ON public.work_course_bookings FOR EACH ROW EXECUTE FUNCTION public.set_updated_at()", "name": "work_course_bookings_updated_at", "table": "work_course_bookings" + }, + { + "def": "CREATE TRIGGER work_team_move_revokes_supervisors AFTER UPDATE OF hospital_id ON public.work_hospital_teams FOR EACH ROW EXECUTE FUNCTION public.work_revoke_supervisors_on_team_move()", + "name": "work_team_move_revokes_supervisors", + "table": "work_hospital_teams" + }, + { + "def": "CREATE TRIGGER work_whole_team_supervisor_scope BEFORE INSERT OR UPDATE ON public.work_role_grants FOR EACH ROW EXECUTE FUNCTION public.work_guard_whole_team_supervisor()", + "name": "work_whole_team_supervisor_scope", + "table": "work_role_grants" } ], "functions": [ @@ -18824,7 +18848,15 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "eefeb8717ae64afc070c1c312a5daf12", + "def_hash": "bc89ab638134abac5c12dd51fbce0284", + "signature": "public.cme_remove_evidence_v2(uuid,uuid,text)" + }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "d49e3fb5c0812ebc682ae49dce4ff426", "signature": "public.cme_remove_evidence(uuid,uuid,text)" }, { @@ -19687,6 +19719,14 @@ "def_hash": "4511c6bd98f3219a61f5ddd44f644e76", "signature": "public.purge_expired_rag_response_cache(integer)" }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "6495e84c303f7bfb857743ff77c613ee", + "signature": "public.rag_cache_scope_revision_v1(uuid,boolean,uuid[])" + }, { "acl": [ "postgres=X/postgres", @@ -19824,6 +19864,14 @@ "def_hash": "874163cf5aa0b818f3e88ca1ac6f90b2", "signature": "public.record_source_review(uuid,uuid,text,text,text[],date,uuid)" }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "6855830fb99d24b7e0d3d47f477ffbbb", + "signature": "public.recover_ingestion_job(uuid,text,jsonb)" + }, { "acl": [ "postgres=X/postgres", @@ -20656,7 +20704,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "a614e81532918d22c3a44d3d4008c54e", + "def_hash": "e0f61bafe44727727e8d3c8ff1c8164e", "signature": "public.teaching_depth_command(uuid,uuid,text,jsonb)" }, { @@ -21016,6 +21064,30 @@ "def_hash": "648b41850477258c0d015136b043bb98", "signature": "public.work_cancel_course(uuid,uuid)" }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "f7b8cb8cfd954642c48c0358ddd8f619", + "signature": "public.work_guard_whole_team_supervisor()" + }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "fdf89b88112ab0575fe3e595348dc200", + "signature": "public.work_move_team_v1(uuid,uuid,uuid,uuid)" + }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "53214a1cf5949106be9192b3460ef059", + "signature": "public.work_revoke_supervisors_on_team_move()" + }, { "acl": [ "postgres=X/postgres", @@ -24722,6 +24794,11 @@ "name": "source_review_events_reason_check", "table": "source_review_events" }, + { + "def": "FOREIGN KEY (cme_evidence_id) REFERENCES public.cme_evidence(id) ON DELETE SET NULL", + "name": "storage_cleanup_jobs_cme_evidence_id_fkey", + "table": "storage_cleanup_jobs" + }, { "def": "FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE SET NULL", "name": "storage_cleanup_jobs_document_id_fkey", diff --git a/supabase/migrations/20261018082910_audit_durable_recovery_pairing_and_cache_fences.sql b/supabase/migrations/20261018082910_audit_durable_recovery_pairing_and_cache_fences.sql new file mode 100644 index 0000000000..edbc209576 --- /dev/null +++ b/supabase/migrations/20261018082910_audit_durable_recovery_pairing_and_cache_fences.sql @@ -0,0 +1,569 @@ +-- DRAFT. Merge only inside the owner's approved database-change window. +set local lock_timeout = '5s'; +set local statement_timeout = '30s'; + +-- F02: the tombstone cannot commit without its exact, durable cleanup obligation. +alter table public.storage_cleanup_jobs add column cme_evidence_id uuid + references public.cme_evidence(id) on delete set null; +create unique index storage_cleanup_jobs_cme_evidence_key on public.storage_cleanup_jobs(cme_evidence_id) + where cme_evidence_id is not null; +create or replace function public.cme_remove_evidence(p_owner_id uuid, p_evidence_id uuid, p_reason text) +returns public.cme_evidence +language plpgsql security invoker set search_path = public, pg_temp as $$ +declare v_row public.cme_evidence%rowtype; +begin + if p_reason is null or length(btrim(p_reason)) not between 3 and 500 then + raise exception 'cme_evidence_removal_reason_invalid'; + end if; + perform pg_advisory_xact_lock(hashtextextended(p_owner_id::text, 23092026)); + select * into v_row from public.cme_evidence where id = p_evidence_id and owner_id = p_owner_id for update; + if not found then raise exception 'cme_evidence_not_found'; end if; + insert into public.storage_cleanup_jobs(owner_id, cme_evidence_id, document_bucket, document_paths, image_bucket, image_paths) + values (p_owner_id, v_row.id, 'cme-private-evidence', array[v_row.storage_path], 'cme-private-evidence', '{}') + on conflict (cme_evidence_id) where cme_evidence_id is not null do nothing; + if v_row.removed_at is null then + update public.cme_evidence set removed_at = now(), removal_reason = btrim(p_reason), file_name = 'Removed file' + where id = v_row.id and owner_id = p_owner_id returning * into v_row; + end if; + return v_row; +end $$; +alter function public.cme_remove_evidence(uuid,uuid,text) owner to postgres; +revoke all on function public.cme_remove_evidence(uuid,uuid,text) from public, anon, authenticated; +grant execute on function public.cme_remove_evidence(uuid,uuid,text) to service_role; + +-- The versioned entry point makes app-before-SQL fail before any tombstone write. +-- Retain the corrected original for old callers during a SQL-first rollout. +create function public.cme_remove_evidence_v2(p_owner_id uuid,p_evidence_id uuid,p_reason text) +returns public.cme_evidence language sql security invoker set search_path = '' as $$ + select public.cme_remove_evidence(p_owner_id,p_evidence_id,p_reason) +$$; +alter function public.cme_remove_evidence_v2(uuid,uuid,text) owner to postgres; +revoke all on function public.cme_remove_evidence_v2(uuid,uuid,text) from public, anon, authenticated; +grant execute on function public.cme_remove_evidence_v2(uuid,uuid,text) to service_role; + +-- Reconcile pre-existing tombstones too, including earlier lost-response removals. +-- Deleting an already-absent object is idempotent in the existing janitor. +insert into public.storage_cleanup_jobs(owner_id,cme_evidence_id,document_bucket,document_paths,image_bucket,image_paths) +select owner_id,id,'cme-private-evidence',array[storage_path],'cme-private-evidence','{}'::text[] +from public.cme_evidence where removed_at is not null +on conflict (cme_evidence_id) where cme_evidence_id is not null do nothing; + +-- F03: recovery owns the same authority and job locks as claims until its single +-- scheduling transaction commits. Preserve every artifact and generation. A next +-- worker claim mints a new write generation; no obsolete unfenced reset is called. +create function public.recover_ingestion_job(p_job_id uuid, p_action text, p_expected jsonb) +returns jsonb language plpgsql security definer set search_path = '' set lock_timeout = '5s' as $$ +declare v_document_id uuid; v_locked jsonb; v_job public.ingestion_jobs%rowtype; v_now timestamptz; v_active boolean; v_indexed boolean; +begin + if p_action is null or p_action not in ('retry','supersede') + or jsonb_typeof(p_expected) is distinct from 'object' + or not (p_expected ?& array['jobStatus','jobLockedAt','jobLeaseToken','jobLeaseExpiresAt','documentStatus','documentOwnerId']) then + raise exception 'invalid_ingestion_recovery_request'; + end if; + select document_id into v_document_id from public.ingestion_jobs where id = p_job_id; + if not found then return jsonb_build_object('ok',false,'reason','job_closed'); end if; + v_locked := public.lock_ingestion_document_authority(v_document_id, false); + if v_locked is null then return jsonb_build_object('ok',false,'reason','authority_unavailable'); end if; + perform 1 from public.ingestion_jobs where document_id = v_document_id order by id for update; + perform 1 from public.indexing_v3_agent_jobs where document_id = v_document_id order by id for update; + select * into v_job from public.ingestion_jobs where id = p_job_id and document_id = v_document_id; + if not found or v_job.status not in ('failed','processing') + or v_job.status is distinct from p_expected->>'jobStatus' + or v_job.locked_at is distinct from (p_expected->>'jobLockedAt')::timestamptz + or v_job.lease_token is distinct from (p_expected->>'jobLeaseToken')::uuid + or v_job.lease_expires_at is distinct from (p_expected->>'jobLeaseExpiresAt')::timestamptz + or v_locked->'document'->>'status' is distinct from p_expected->>'documentStatus' + or v_locked->'document'->>'owner_id' is distinct from p_expected->>'documentOwnerId' then + return jsonb_build_object('ok',false,'reason','state_changed'); + end if; + v_now := pg_catalog.clock_timestamp(); + if v_job.status = 'processing' and v_job.lease_expires_at > v_now then + return jsonb_build_object('ok',false,'reason','active_lease'); + end if; + v_active := exists(select 1 from public.ingestion_jobs where document_id = v_document_id and id <> p_job_id + and (status = 'pending' or status = 'processing' and lease_expires_at > v_now)) + or exists(select 1 from public.indexing_v3_agent_jobs where document_id = v_document_id + and status = 'processing' and lease_expires_at > v_now); + v_indexed := v_locked->'document'->>'status' = 'indexed' + and coalesce((v_locked->'document'->>'chunk_count')::integer,0) > 0; + if p_action = 'retry' then + if v_active or v_indexed or exists(select 1 from public.ingestion_jobs where document_id = v_document_id + and id <> p_job_id and status in ('pending','processing')) then + return jsonb_build_object('ok',false,'reason','state_changed'); + end if; + if exists(select 1 from ingestion_generation_evidence.candidates where document_id = v_document_id) then + return jsonb_build_object('ok',false,'reason','managed_recovery_required'); + end if; + update public.documents set status = 'queued', error_message = null where id = v_document_id; + elsif not (v_active or v_indexed) then + -- Stale redundant siblings may be closed before retry, in stable job id order. + if not exists(select 1 from public.ingestion_jobs where document_id = v_document_id and id <> p_job_id + and status in ('failed','processing')) then + return jsonb_build_object('ok',false,'reason','state_changed'); + end if; + end if; + update public.ingestion_jobs set status = case when p_action = 'retry' then 'pending' else 'completed' end, + stage = case when p_action = 'retry' then 'queued after recovery' else 'superseded during recovery' end, + progress = case when p_action = 'retry' then 0 else 100 end, + attempt_count = case when p_action = 'retry' then 0 else attempt_count end, + next_run_at = case when p_action = 'retry' then v_now else null end, + completed_at = case when p_action = 'retry' then null else v_now end, + error_message = null, locked_by = null, locked_at = null, + lease_token = null, lease_expected_generation_id = null, lease_write_generation_id = null, + lease_authority_digest = null, lease_expires_at = null, lease_duration_seconds = null + where id = p_job_id; + return jsonb_build_object('ok',true,'action',p_action,'artifacts_preserved',true); +end $$; +alter function public.recover_ingestion_job(uuid,text,jsonb) owner to postgres; +revoke all on function public.recover_ingestion_job(uuid,text,jsonb) from public, anon, authenticated; +grant execute on function public.recover_ingestion_job(uuid,text,jsonb) to service_role; + +-- F13: one statement snapshot of the complete effective owner/public/document scope. +-- Per-row fixed-size hashes bound aggregate memory without returning document metadata. +create function public.rag_cache_scope_revision_v1(p_owner_id uuid, p_include_public boolean, p_document_ids uuid[] default null) +returns text language sql stable security invoker set search_path = '' as $$ + select 'rag-cache-scope-v1:' || pg_catalog.md5(coalesce(pg_catalog.string_agg(row_hash, '' order by id),'')) + from ( + select d.id, pg_catalog.md5(pg_catalog.jsonb_build_array(d.id,d.owner_id,d.status,d.updated_at,d.metadata,d.index_generation_id)::text) as row_hash + from public.documents d + where (d.owner_id = p_owner_id or (coalesce(p_include_public,false) and d.owner_id is null)) + and (coalesce(cardinality(p_document_ids),0) = 0 or d.id = any(p_document_ids)) + ) revision +$$; +alter function public.rag_cache_scope_revision_v1(uuid,boolean,uuid[]) owner to postgres; +revoke all on function public.rag_cache_scope_revision_v1(uuid,boolean,uuid[]) from public, anon, authenticated; +grant execute on function public.rag_cache_scope_revision_v1(uuid,boolean,uuid[]) to service_role; + +-- HY05: team movement and removal of its whole-team authority commit together. +create function public.work_revoke_supervisors_on_team_move() returns trigger +language plpgsql security invoker set search_path = '' as $$ +begin + if new.hospital_id is distinct from old.hospital_id then + update public.work_role_grants set revoked_at = now(), revoked_by = new.linked_by + where role = 'supervisor' and service_id = new.service_id and subject_user_id is null and revoked_at is null; + end if; + return new; +end $$; +alter function public.work_revoke_supervisors_on_team_move() owner to postgres; +revoke all on function public.work_revoke_supervisors_on_team_move() from public, anon, authenticated; +grant execute on function public.work_revoke_supervisors_on_team_move() to service_role; +create trigger work_team_move_revokes_supervisors after update of hospital_id on public.work_hospital_teams +for each row execute function public.work_revoke_supervisors_on_team_move(); + +-- A grant admitted against an old hospital must not race the move. Both paths +-- lock the team before writing authority. Revocation itself needs no team lookup. +create function public.work_guard_whole_team_supervisor() returns trigger +language plpgsql security invoker set search_path = '' as $$ +begin + if new.role = 'supervisor' and new.service_id is not null and new.revoked_at is null then + perform 1 from public.work_hospital_teams where service_id = new.service_id and hospital_id = new.hospital_id for share; + if not found then raise exception 'work_team_changed'; end if; + end if; + return new; +end $$; +alter function public.work_guard_whole_team_supervisor() owner to postgres; +revoke all on function public.work_guard_whole_team_supervisor() from public, anon, authenticated; +grant execute on function public.work_guard_whole_team_supervisor() to service_role; +create trigger work_whole_team_supervisor_scope before insert or update on public.work_role_grants +for each row execute function public.work_guard_whole_team_supervisor(); + +-- Versioned application entry point: refuse app-before-SQL instead of moving +-- without the transactional revocation trigger. Old callers remain trigger-protected. +create function public.work_move_team_v1(p_actor_id uuid,p_service_id uuid,p_expected_hospital_id uuid,p_to_hospital_id uuid) +returns uuid language plpgsql security definer set search_path = '' as $$ +begin + if not exists(select 1 from auth.users where id = p_actor_id + and raw_app_meta_data->>'site_role' = 'administrator') then + raise exception 'work_role_denied'; + end if; + perform 1 from public.work_hospitals where id = p_to_hospital_id and archived_at is null for share; + if not found then raise exception 'work_hospital_unavailable'; end if; + if p_expected_hospital_id is not distinct from p_to_hospital_id then raise exception 'work_team_changed'; end if; + update public.work_hospital_teams set hospital_id = p_to_hospital_id, linked_by = p_actor_id, linked_at = now() + where service_id = p_service_id and hospital_id = p_expected_hospital_id; + if not found then raise exception 'work_team_changed'; end if; + return p_expected_hospital_id; +end $$; +alter function public.work_move_team_v1(uuid,uuid,uuid,uuid) owner to postgres; +revoke all on function public.work_move_team_v1(uuid,uuid,uuid,uuid) from public, anon, authenticated; +grant execute on function public.work_move_team_v1(uuid,uuid,uuid,uuid) to service_role; + +-- F14 / HY06: pairing authority is locked before authorization and child writes. +create or replace function public.teaching_depth_command(p_actor_id uuid, p_service_id uuid, p_action text, p_payload jsonb default '{}') returns jsonb +language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$ +declare + v_service public.on_call_services; + v_role text; + v_occ public.teaching_occurrences; + v_pairing public.teaching_supervision_pairings; + v_entry public.teaching_supervision_entries; + v_note public.teaching_supervision_notes; + v_readiness public.teaching_readiness; + v_id uuid; + v_registrar uuid; + v_supervisor uuid; + v_ids uuid[]; + v_topics text[]; + v_starts date; + v_ends date; + v_date date; + v_target numeric; + v_text text; + v_minutes integer; + v_useful integer; + v_count integer; + v_result jsonb; + v_row jsonb; + v_leaver boolean := false; +begin + if p_actor_id is null then raise exception 'teaching_auth_required'; end if; + if p_action is null or p_payload is null or jsonb_typeof(p_payload) <> 'object' or p_service_id is null + or p_action not in ('pairing.save','pairing.reassign','supervision.log','supervision.confirm','supervision.note', + 'supervision.note.confirm','supervision.read','supervision.target.set','readiness.set','readiness.deid.confirm', + 'feedback.submit','feedback.totals','import.commit') then + raise exception 'teaching_invalid_request'; + end if; + select * into v_service from public.on_call_services where id = p_service_id for share; + if not found then raise exception 'teaching_access_denied'; end if; + v_role := public.teaching_member_role(p_service_id, p_actor_id); + if v_role is null then + -- A leaver keeps read-only access to their own supervision entries (as registrar) for 90 days + -- from the shared revoke, as logbook.read keeps their attendance (spec §9). Nothing else. + v_leaver := p_action = 'supervision.read' and exists (select 1 from public.on_call_service_members m + where m.service_id = p_service_id and m.user_id = p_actor_id + and m.revoked_at is not null and m.revoked_at > now() - interval '90 days'); + if not v_leaver then raise exception 'teaching_access_denied'; end if; + end if; + if p_action not in ('supervision.read','feedback.totals') and not (v_service.verified_at is not null or v_service.is_demo) then + raise exception 'teaching_team_unverified'; + end if; + if p_action in ('pairing.save','pairing.reassign','import.commit') then + perform pg_advisory_xact_lock(hashtextextended(p_service_id::text, 74818)); + end if; + + if p_action = 'pairing.save' then + v_id := public.teaching_uuid_arg(p_payload, 'pairingId'); + v_registrar := public.teaching_uuid_arg(p_payload, 'registrarId'); + v_supervisor := public.teaching_uuid_arg(p_payload, 'supervisorId'); + v_starts := public.teaching_date_arg(p_payload, 'startsOn'); + v_ends := public.teaching_date_arg(p_payload, 'endsOn'); + -- The hours target is the registrar's own (supervision.target.set); an organiser never sets it. + if p_payload ? 'targetHours' then raise exception 'teaching_invalid_request'; end if; + -- Organisers pair other people. A pairing that includes the actor needs a Teaching admin. + if not (v_role = 'admin' or (v_role = 'organiser' and p_actor_id <> v_registrar and p_actor_id <> v_supervisor)) then + raise exception 'teaching_role_denied'; + end if; + if v_registrar is null or v_supervisor is null or v_registrar = v_supervisor + or v_starts is null or v_ends is null or v_ends < v_starts or v_ends > v_starts + 731 + or not public.service_member_active(p_service_id, v_registrar) + or not public.service_member_active(p_service_id, v_supervisor) then + raise exception 'teaching_invalid_request'; + end if; + if v_id is null then + if (select count(*) from public.teaching_supervision_pairings where service_id = p_service_id) >= 5000 then + raise exception 'teaching_limit'; + end if; + insert into public.teaching_supervision_pairings(service_id, registrar_id, supervisor_id, starts_on, ends_on, created_by) + values (p_service_id, v_registrar, v_supervisor, v_starts, v_ends, p_actor_id) + returning * into v_pairing; + else + select * into v_pairing from public.teaching_supervision_pairings where id = v_id and service_id = p_service_id for update; + if not found then raise exception 'teaching_not_found'; end if; + -- Once a pairing has entries, only its end date and target can change; the supervisor + -- changes through pairing.reassign. + if exists (select 1 from public.teaching_supervision_entries e where e.pairing_id = v_pairing.id) + and (v_pairing.registrar_id is distinct from v_registrar or v_pairing.supervisor_id is distinct from v_supervisor + or v_pairing.starts_on <> v_starts) then + raise exception 'teaching_invalid_request'; + end if; + update public.teaching_supervision_pairings set registrar_id = v_registrar, supervisor_id = v_supervisor, + starts_on = v_starts, ends_on = v_ends, updated_at = now() + where id = v_pairing.id returning * into v_pairing; + end if; + perform public.teaching_audit(p_service_id, p_actor_id, 'pairing.save', v_pairing.id); + return jsonb_build_object('pairingId', v_pairing.id); + + elsif p_action = 'pairing.reassign' then + v_id := public.teaching_uuid_arg(p_payload, 'pairingId'); + v_supervisor := public.teaching_uuid_arg(p_payload, 'supervisorId'); + select * into v_pairing from public.teaching_supervision_pairings where id = v_id and service_id = p_service_id for update; + if not found then raise exception 'teaching_not_found'; end if; + if not (v_role = 'admin' or (v_role = 'organiser' and p_actor_id is distinct from v_pairing.registrar_id and p_actor_id <> v_supervisor)) then + raise exception 'teaching_role_denied'; + end if; + if v_supervisor is null or v_supervisor is not distinct from v_pairing.registrar_id + or not public.service_member_active(p_service_id, v_supervisor) then + raise exception 'teaching_invalid_request'; + end if; + -- Confirmed entries keep who confirmed them; pending entries and notes now wait for the new + -- supervisor, because confirmation always checks the pairing's current supervisor. + update public.teaching_supervision_pairings set supervisor_id = v_supervisor, updated_at = now() where id = v_pairing.id; + select count(*) into v_count from public.teaching_supervision_entries where pairing_id = v_pairing.id and status = 'pending'; + perform public.teaching_audit(p_service_id, p_actor_id, 'pairing.reassign', v_pairing.id); + return jsonb_build_object('pairingId', v_pairing.id, 'pendingMoved', v_count); + + elsif p_action = 'supervision.target.set' then + -- The registrar's own hours target for this pairing: nullable, no default, and progress is shown + -- only once it is set. Only the pairing's registrar sets or clears it (targetHours null). + v_id := public.teaching_uuid_arg(p_payload, 'pairingId'); + select * into v_pairing from public.teaching_supervision_pairings where id = v_id and service_id = p_service_id for update; + if not found then raise exception 'teaching_not_found'; end if; + if v_pairing.registrar_id is distinct from p_actor_id then raise exception 'teaching_role_denied'; end if; + if not (p_payload ? 'targetHours') then raise exception 'teaching_invalid_request'; end if; + if p_payload->'targetHours' <> 'null'::jsonb then + if jsonb_typeof(p_payload->'targetHours') <> 'number' or (p_payload->>'targetHours') !~ '^[0-9]{1,3}(\.[0-9])?$' then + raise exception 'teaching_invalid_request'; + end if; + v_target := (p_payload->>'targetHours')::numeric; + if v_target not between 1 and 500 then raise exception 'teaching_invalid_request'; end if; + end if; + update public.teaching_supervision_pairings set target_hours = v_target, updated_at = now() where id = v_pairing.id; + perform public.teaching_audit(p_service_id, p_actor_id, 'supervision.target.set', v_pairing.id); + return jsonb_build_object('pairingId', v_pairing.id, 'targetHours', v_target); + + elsif p_action = 'supervision.log' then + v_id := public.teaching_uuid_arg(p_payload, 'pairingId'); + select * into v_pairing from public.teaching_supervision_pairings where id = v_id and service_id = p_service_id for update; + if not found then raise exception 'teaching_not_found'; end if; + if v_pairing.registrar_id is distinct from p_actor_id then raise exception 'teaching_role_denied'; end if; + v_date := public.teaching_date_arg(p_payload, 'date'); + v_minutes := public.teaching_int_arg(p_payload, 'minutes'); + v_text := public.teaching_text_arg(p_payload, 'type'); + v_topics := public.teaching_text_array_arg(p_payload, 'topics', 5); + if v_date is null or v_date < v_pairing.starts_on or v_date > v_pairing.ends_on + or v_date > (now() at time zone 'Australia/Perth')::date + or v_minutes is null or v_minutes not between 15 and 240 or v_minutes % 15 <> 0 + or v_text is null or v_text not in ('individual','group') + -- Topics are optional: none to five. + or cardinality(v_topics) not between 0 and 5 + or not (v_topics <@ array['case_review','risk','psychotherapy','formulation','medication','mha_legal','teaching_skills','career','exam_prep','wellbeing','other']::text[]) then + raise exception 'teaching_invalid_request'; + end if; + if (select count(*) from public.teaching_supervision_entries where pairing_id = v_pairing.id) >= 1000 then + raise exception 'teaching_limit'; + end if; + insert into public.teaching_supervision_entries(pairing_id, service_id, session_date, minutes, type, topics, logged_by) + values (v_pairing.id, p_service_id, v_date, v_minutes, v_text, v_topics, p_actor_id) + returning * into v_entry; + perform public.teaching_audit(p_service_id, p_actor_id, 'supervision.log', v_entry.id); + return jsonb_build_object('entryId', v_entry.id); + + elsif p_action = 'supervision.confirm' then + v_ids := public.teaching_uuid_array_arg(p_payload, 'entryIds', 20); + if cardinality(v_ids) = 0 then raise exception 'teaching_invalid_request'; end if; + -- Parent before child, sorted for multi-pairing batches. Authorization is checked + -- after any wait, so confirmation linearizes with reassignment; a confirmation + -- committed first remains historical evidence under its original supervisor. + perform p.id from public.teaching_supervision_pairings p + where p.service_id = p_service_id and p.id in ( + select e.pairing_id from public.teaching_supervision_entries e + where e.id = any(v_ids) and e.service_id = p_service_id) + order by p.id for update; + perform e.id from public.teaching_supervision_entries e + where e.id = any(v_ids) and e.service_id = p_service_id order by e.id for update; + -- All or nothing: every entry must be pending and belong to a pairing the actor now supervises. + if (select count(*) from public.teaching_supervision_entries e + join public.teaching_supervision_pairings p on p.id = e.pairing_id + where e.id = any(v_ids) and e.service_id = p_service_id and e.status = 'pending' and p.supervisor_id = p_actor_id) + <> cardinality(v_ids) then + raise exception 'teaching_not_found'; + end if; + update public.teaching_supervision_entries set status = 'confirmed', confirmed_by = p_actor_id, confirmed_at = now(), locked = true + where id = any(v_ids); + insert into public.teaching_audit_events(service_id, actor_id, action, subject_id) + select p_service_id, p_actor_id, 'supervision.confirm', u from unnest(v_ids) as u; + return jsonb_build_object('confirmed', cardinality(v_ids)); + + elsif p_action = 'supervision.note' then + v_id := public.teaching_uuid_arg(p_payload, 'entryId'); + perform p.id from public.teaching_supervision_pairings p + join public.teaching_supervision_entries e on e.pairing_id = p.id + where e.id = v_id and e.service_id = p_service_id for update of p; + select e.* into v_entry from public.teaching_supervision_entries e + join public.teaching_supervision_pairings p on p.id = e.pairing_id + where e.id = v_id and e.service_id = p_service_id and p.registrar_id = p_actor_id; + if not found then raise exception 'teaching_not_found'; end if; + v_text := public.teaching_text_arg(p_payload, 'reason'); + if v_text is null or not public.teaching_valid_correction(v_text, p_payload->'correctedValue') then + raise exception 'teaching_invalid_request'; + end if; + if v_text = 'wrong_date' then + perform public.teaching_date_arg(p_payload->'correctedValue', 'date'); + end if; + if (select count(*) from public.teaching_supervision_notes where entry_id = v_entry.id and confirmed_at is null) >= 5 then + raise exception 'teaching_limit'; + end if; + insert into public.teaching_supervision_notes(entry_id, service_id, reason, corrected_value, created_by) + values (v_entry.id, p_service_id, v_text, p_payload->'correctedValue', p_actor_id) + returning * into v_note; + perform public.teaching_audit(p_service_id, p_actor_id, 'supervision.note', v_note.id); + return jsonb_build_object('noteId', v_note.id); + + elsif p_action = 'supervision.note.confirm' then + v_id := public.teaching_uuid_arg(p_payload, 'noteId'); + perform p.id from public.teaching_supervision_pairings p + join public.teaching_supervision_entries e on e.pairing_id = p.id + join public.teaching_supervision_notes n on n.entry_id = e.id + where n.id = v_id and n.service_id = p_service_id for update of p; + select n.* into v_note from public.teaching_supervision_notes n + join public.teaching_supervision_entries e on e.id = n.entry_id + join public.teaching_supervision_pairings p on p.id = e.pairing_id + where n.id = v_id and n.service_id = p_service_id and p.supervisor_id = p_actor_id and n.confirmed_at is null + for update of n; + if not found then raise exception 'teaching_not_found'; end if; + update public.teaching_supervision_notes set confirmed_by = p_actor_id, confirmed_at = now() where id = v_note.id; + perform public.teaching_audit(p_service_id, p_actor_id, 'supervision.note.confirm', v_note.id); + return jsonb_build_object('noteId', v_note.id); + + elsif p_action = 'supervision.read' then + -- The registrar and supervisor of a pairing see its entries; an organiser sees totals and + -- status only (never topics). Reading anyone else's pairing is audited. A leaver (v_leaver) + -- sees only the pairings where they are the registrar, and reads their own record unaudited. + v_id := public.teaching_uuid_arg(p_payload, 'pairingId'); + if v_id is not null and not exists ( + select 1 from public.teaching_supervision_pairings p where p.id = v_id and p.service_id = p_service_id + and (p.registrar_id = p_actor_id or (not v_leaver and (p.supervisor_id = p_actor_id or v_role = 'organiser')))) then + raise exception 'teaching_not_found'; + end if; + if v_id is null and v_role = 'organiser' then + -- An organiser's whole-team read (Logbook, Today): one service-level row per call. + perform public.teaching_audit(p_service_id, p_actor_id, 'supervision.read', null); + elsif not v_leaver then + insert into public.teaching_audit_events(service_id, actor_id, action, subject_id) + select p_service_id, p_actor_id, 'supervision.read', p.id + from public.teaching_supervision_pairings p + where p.service_id = p_service_id and (v_id is null or p.id = v_id) + and p.registrar_id is distinct from p_actor_id + and (p.supervisor_id = p_actor_id or v_role = 'organiser'); + end if; + select jsonb_build_object('pairings', coalesce(jsonb_agg(jsonb_build_object( + 'pairingId', p.id, + 'access', case when p.registrar_id = p_actor_id then 'registrar' when p.supervisor_id = p_actor_id then 'supervisor' else 'organiser' end, + 'registrarName', case when p.registrar_id is null then 'Former member' else coalesce((select coalesce(m.display_name, 'Member') + from public.on_call_service_members m where m.service_id = p.service_id and m.user_id = p.registrar_id), 'Former member') end, + 'supervisorName', case when p.supervisor_id is null then 'Former member' else coalesce((select coalesce(m.display_name, 'Member') + from public.on_call_service_members m where m.service_id = p.service_id and m.user_id = p.supervisor_id), 'Former member') end, + 'startsOn', p.starts_on, 'endsOn', p.ends_on, + 'targetHours', case when p.registrar_id = p_actor_id or p.supervisor_id = p_actor_id then p.target_hours else null end, + 'confirmedMinutes', (select coalesce(sum(e.minutes), 0) from public.teaching_supervision_entries e where e.pairing_id = p.id and e.status = 'confirmed'), + 'pendingMinutes', (select coalesce(sum(e.minutes), 0) from public.teaching_supervision_entries e where e.pairing_id = p.id and e.status = 'pending'), + 'pendingCount', (select count(*) from public.teaching_supervision_entries e where e.pairing_id = p.id and e.status = 'pending'), + 'oldestPendingAt', (select min(e.created_at) from public.teaching_supervision_entries e where e.pairing_id = p.id and e.status = 'pending'), + 'entries', case when p.registrar_id = p_actor_id or p.supervisor_id = p_actor_id then coalesce((select jsonb_agg(jsonb_build_object( + 'entryId', e.id, 'date', e.session_date, 'minutes', e.minutes, 'type', e.type, 'topics', to_jsonb(e.topics), + 'status', e.status, 'confirmedAt', e.confirmed_at, + 'confirmedByName', case when e.confirmed_at is null then null when e.confirmed_by is null then 'Former member' else coalesce( + (select coalesce(m.display_name, 'Member') from public.on_call_service_members m where m.service_id = e.service_id and m.user_id = e.confirmed_by), + 'Former member') end, + 'notes', coalesce((select jsonb_agg(jsonb_build_object('noteId', n.id, 'reason', n.reason, 'correctedValue', n.corrected_value, + 'createdAt', n.created_at, 'confirmedAt', n.confirmed_at) order by n.created_at) + from public.teaching_supervision_notes n where n.entry_id = e.id), '[]'::jsonb) + ) order by e.session_date desc, e.created_at desc) from public.teaching_supervision_entries e where e.pairing_id = p.id), '[]'::jsonb) + else null end + ) order by p.starts_on desc, p.id), '[]'::jsonb)) + into v_result + from public.teaching_supervision_pairings p + where p.service_id = p_service_id and (v_id is null or p.id = v_id) + and (p.registrar_id = p_actor_id or (not v_leaver and (p.supervisor_id = p_actor_id or v_role = 'organiser'))); + return v_result; + + elsif p_action in ('readiness.set','readiness.deid.confirm') then + v_id := public.teaching_uuid_arg(p_payload, 'occurrenceId'); + select * into v_occ from public.teaching_occurrences where id = v_id and service_id = p_service_id; + if not found then raise exception 'teaching_not_found'; end if; + if v_occ.presenter_id is distinct from p_actor_id then raise exception 'teaching_role_denied'; end if; + insert into public.teaching_readiness(occurrence_id, service_id) values (v_occ.id, p_service_id) + on conflict (occurrence_id) do nothing; + if p_action = 'readiness.set' then + v_text := public.teaching_text_arg(p_payload, 'item'); + if v_text is null or v_text not in ('reading_list','aims','slides_link','room') + or jsonb_typeof(p_payload->'done') is distinct from 'boolean' then + raise exception 'teaching_invalid_request'; + end if; + update public.teaching_readiness set + items = case when (p_payload->>'done')::boolean + then array(select distinct i from unnest(array_append(items, v_text)) as i order by i) + else array_remove(items, v_text) end, + updated_at = now() + where occurrence_id = v_occ.id returning * into v_readiness; + else + update public.teaching_readiness set deid_confirmed_at = now(), deid_confirmed_by = p_actor_id, updated_at = now() + where occurrence_id = v_occ.id returning * into v_readiness; + end if; + perform public.teaching_audit(p_service_id, p_actor_id, p_action, v_occ.id); + return jsonb_build_object('items', to_jsonb(v_readiness.items), 'deidConfirmedAt', v_readiness.deid_confirmed_at); + + elsif p_action = 'feedback.submit' then + v_id := public.teaching_uuid_arg(p_payload, 'occurrenceId'); + select * into v_occ from public.teaching_occurrences where id = v_id and service_id = p_service_id; + if not found then raise exception 'teaching_not_found'; end if; + if not exists (select 1 from public.teaching_attendance a where a.occurrence_id = v_occ.id and a.user_id = p_actor_id) then + raise exception 'teaching_not_attended'; + end if; + if v_occ.status = 'cancelled' or now() < v_occ.ends_at or now() > v_occ.ends_at + interval '7 days' then + raise exception 'teaching_window_closed'; + end if; + v_useful := public.teaching_int_arg(p_payload, 'useful'); + v_text := public.teaching_text_arg(p_payload, 'pace'); + if v_useful is null or v_useful not between 1 and 5 or v_text is null or v_text not in ('slow','right','fast') then + raise exception 'teaching_invalid_request'; + end if; + -- One-way "already answered" marker. No responder id is stored with feedback anywhere. + insert into public.teaching_feedback_replied(occurrence_id, reply_marker) + values (v_occ.id, encode(extensions.digest(convert_to('teaching-feedback:' || v_occ.id::text || ':' || p_actor_id::text, 'UTF8'), 'sha256'), 'hex')) + on conflict (occurrence_id, reply_marker) do nothing; + if not found then raise exception 'teaching_limit'; end if; + insert into public.teaching_feedback_answers(occurrence_id, service_id, useful, pace) + values (v_occ.id, p_service_id, v_useful, v_text); + -- No actor on this audit row: nothing may tie a person to the moment an answer was written. + perform public.teaching_audit(p_service_id, null, 'feedback.submit', v_occ.id); + return '{}'::jsonb; + + elsif p_action = 'import.commit' then + -- A term from a spreadsheet, all or nothing: every row is a new series through the same save as + -- series.save, inside this one transaction, so one refused row rolls back every row before it. + -- Spec §4 gives import to organisers and admins. + if v_role not in ('organiser','admin') then raise exception 'teaching_role_denied'; end if; + if jsonb_typeof(p_payload->'rows') is distinct from 'array' + or jsonb_array_length(p_payload->'rows') not between 1 and 200 then + raise exception 'teaching_invalid_request'; + end if; + v_count := 0; + for v_row in select r.value from jsonb_array_elements(p_payload->'rows') as r(value) loop + if jsonb_typeof(v_row) is distinct from 'object' or v_row ? 'seriesId' then + raise exception 'teaching_invalid_request'; + end if; + v_result := public.teaching_series_save(p_actor_id, p_service_id, v_row); + perform public.teaching_audit(p_service_id, p_actor_id, 'series.save', public.teaching_uuid_arg(v_result, 'seriesId')); + v_count := v_count + public.teaching_int_arg(v_result, 'occurrences'); + end loop; + perform public.teaching_audit(p_service_id, p_actor_id, 'import.commit', null); + return jsonb_build_object('series', jsonb_array_length(p_payload->'rows'), 'occurrences', v_count); + + elsif p_action = 'feedback.totals' then + v_id := public.teaching_uuid_arg(p_payload, 'occurrenceId'); + select * into v_occ from public.teaching_occurrences where id = v_id and service_id = p_service_id; + if not found then raise exception 'teaching_not_found'; end if; + if (v_occ.presenter_id = p_actor_id or v_role = 'organiser') is not true then raise exception 'teaching_role_denied'; end if; + select count(*) into v_count from public.teaching_feedback_answers where occurrence_id = v_occ.id; + if now() < v_occ.ends_at + interval '7 days' or v_count < 5 then + return jsonb_build_object('released', false); + end if; + perform public.teaching_audit(p_service_id, p_actor_id, 'feedback.totals', v_occ.id); + return jsonb_build_object( + 'released', true, + 'replies', v_count, + 'useful', (select jsonb_build_object('1', count(*) filter (where useful = 1), '2', count(*) filter (where useful = 2), + '3', count(*) filter (where useful = 3), '4', count(*) filter (where useful = 4), '5', count(*) filter (where useful = 5)) + from public.teaching_feedback_answers where occurrence_id = v_occ.id), + 'pace', (select jsonb_build_object('slow', count(*) filter (where pace = 'slow'), 'right', count(*) filter (where pace = 'right'), + 'fast', count(*) filter (where pace = 'fast')) + from public.teaching_feedback_answers where occurrence_id = v_occ.id) + ); + end if; + + raise exception 'teaching_invalid_request'; +end $$; +alter function public.teaching_depth_command(uuid,uuid,text,jsonb) owner to postgres; +revoke all on function public.teaching_depth_command(uuid,uuid,text,jsonb) from public, anon, authenticated; +grant execute on function public.teaching_depth_command(uuid,uuid,text,jsonb) to service_role; diff --git a/supabase/schema.sql b/supabase/schema.sql index 19da197013..8e6db93a47 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -34921,3 +34921,574 @@ end $$; alter function public.claim_ingestion_jobs(text, integer, integer) owner to postgres; revoke all on function public.claim_ingestion_jobs(text, integer, integer) from public, anon, authenticated; grant execute on function public.claim_ingestion_jobs(text, integer, integer) to service_role; + +-- Projection: 20261018082910_audit_durable_recovery_pairing_and_cache_fences.sql +-- DRAFT. Merge only inside the owner's approved database-change window. +set local lock_timeout = '5s'; +set local statement_timeout = '30s'; + +-- F02: the tombstone cannot commit without its exact, durable cleanup obligation. +alter table public.storage_cleanup_jobs add column cme_evidence_id uuid + references public.cme_evidence(id) on delete set null; +create unique index storage_cleanup_jobs_cme_evidence_key on public.storage_cleanup_jobs(cme_evidence_id) + where cme_evidence_id is not null; +create or replace function public.cme_remove_evidence(p_owner_id uuid, p_evidence_id uuid, p_reason text) +returns public.cme_evidence +language plpgsql security invoker set search_path = public, pg_temp as $$ +declare v_row public.cme_evidence%rowtype; +begin + if p_reason is null or length(btrim(p_reason)) not between 3 and 500 then + raise exception 'cme_evidence_removal_reason_invalid'; + end if; + perform pg_advisory_xact_lock(hashtextextended(p_owner_id::text, 23092026)); + select * into v_row from public.cme_evidence where id = p_evidence_id and owner_id = p_owner_id for update; + if not found then raise exception 'cme_evidence_not_found'; end if; + insert into public.storage_cleanup_jobs(owner_id, cme_evidence_id, document_bucket, document_paths, image_bucket, image_paths) + values (p_owner_id, v_row.id, 'cme-private-evidence', array[v_row.storage_path], 'cme-private-evidence', '{}') + on conflict (cme_evidence_id) where cme_evidence_id is not null do nothing; + if v_row.removed_at is null then + update public.cme_evidence set removed_at = now(), removal_reason = btrim(p_reason), file_name = 'Removed file' + where id = v_row.id and owner_id = p_owner_id returning * into v_row; + end if; + return v_row; +end $$; +alter function public.cme_remove_evidence(uuid,uuid,text) owner to postgres; +revoke all on function public.cme_remove_evidence(uuid,uuid,text) from public, anon, authenticated; +grant execute on function public.cme_remove_evidence(uuid,uuid,text) to service_role; + +-- The versioned entry point makes app-before-SQL fail before any tombstone write. +-- Retain the corrected original for old callers during a SQL-first rollout. +create function public.cme_remove_evidence_v2(p_owner_id uuid,p_evidence_id uuid,p_reason text) +returns public.cme_evidence language sql security invoker set search_path = '' as $$ + select public.cme_remove_evidence(p_owner_id,p_evidence_id,p_reason) +$$; +alter function public.cme_remove_evidence_v2(uuid,uuid,text) owner to postgres; +revoke all on function public.cme_remove_evidence_v2(uuid,uuid,text) from public, anon, authenticated; +grant execute on function public.cme_remove_evidence_v2(uuid,uuid,text) to service_role; + +-- Reconcile pre-existing tombstones too, including earlier lost-response removals. +-- Deleting an already-absent object is idempotent in the existing janitor. +insert into public.storage_cleanup_jobs(owner_id,cme_evidence_id,document_bucket,document_paths,image_bucket,image_paths) +select owner_id,id,'cme-private-evidence',array[storage_path],'cme-private-evidence','{}'::text[] +from public.cme_evidence where removed_at is not null +on conflict (cme_evidence_id) where cme_evidence_id is not null do nothing; + +-- F03: recovery owns the same authority and job locks as claims until its single +-- scheduling transaction commits. Preserve every artifact and generation. A next +-- worker claim mints a new write generation; no obsolete unfenced reset is called. +create function public.recover_ingestion_job(p_job_id uuid, p_action text, p_expected jsonb) +returns jsonb language plpgsql security definer set search_path = '' set lock_timeout = '5s' as $$ +declare v_document_id uuid; v_locked jsonb; v_job public.ingestion_jobs%rowtype; v_now timestamptz; v_active boolean; v_indexed boolean; +begin + if p_action is null or p_action not in ('retry','supersede') + or jsonb_typeof(p_expected) is distinct from 'object' + or not (p_expected ?& array['jobStatus','jobLockedAt','jobLeaseToken','jobLeaseExpiresAt','documentStatus','documentOwnerId']) then + raise exception 'invalid_ingestion_recovery_request'; + end if; + select document_id into v_document_id from public.ingestion_jobs where id = p_job_id; + if not found then return jsonb_build_object('ok',false,'reason','job_closed'); end if; + v_locked := public.lock_ingestion_document_authority(v_document_id, false); + if v_locked is null then return jsonb_build_object('ok',false,'reason','authority_unavailable'); end if; + perform 1 from public.ingestion_jobs where document_id = v_document_id order by id for update; + perform 1 from public.indexing_v3_agent_jobs where document_id = v_document_id order by id for update; + select * into v_job from public.ingestion_jobs where id = p_job_id and document_id = v_document_id; + if not found or v_job.status not in ('failed','processing') + or v_job.status is distinct from p_expected->>'jobStatus' + or v_job.locked_at is distinct from (p_expected->>'jobLockedAt')::timestamptz + or v_job.lease_token is distinct from (p_expected->>'jobLeaseToken')::uuid + or v_job.lease_expires_at is distinct from (p_expected->>'jobLeaseExpiresAt')::timestamptz + or v_locked->'document'->>'status' is distinct from p_expected->>'documentStatus' + or v_locked->'document'->>'owner_id' is distinct from p_expected->>'documentOwnerId' then + return jsonb_build_object('ok',false,'reason','state_changed'); + end if; + v_now := pg_catalog.clock_timestamp(); + if v_job.status = 'processing' and v_job.lease_expires_at > v_now then + return jsonb_build_object('ok',false,'reason','active_lease'); + end if; + v_active := exists(select 1 from public.ingestion_jobs where document_id = v_document_id and id <> p_job_id + and (status = 'pending' or status = 'processing' and lease_expires_at > v_now)) + or exists(select 1 from public.indexing_v3_agent_jobs where document_id = v_document_id + and status = 'processing' and lease_expires_at > v_now); + v_indexed := v_locked->'document'->>'status' = 'indexed' + and coalesce((v_locked->'document'->>'chunk_count')::integer,0) > 0; + if p_action = 'retry' then + if v_active or v_indexed or exists(select 1 from public.ingestion_jobs where document_id = v_document_id + and id <> p_job_id and status in ('pending','processing')) then + return jsonb_build_object('ok',false,'reason','state_changed'); + end if; + if exists(select 1 from ingestion_generation_evidence.candidates where document_id = v_document_id) then + return jsonb_build_object('ok',false,'reason','managed_recovery_required'); + end if; + update public.documents set status = 'queued', error_message = null where id = v_document_id; + elsif not (v_active or v_indexed) then + -- Stale redundant siblings may be closed before retry, in stable job id order. + if not exists(select 1 from public.ingestion_jobs where document_id = v_document_id and id <> p_job_id + and status in ('failed','processing')) then + return jsonb_build_object('ok',false,'reason','state_changed'); + end if; + end if; + update public.ingestion_jobs set status = case when p_action = 'retry' then 'pending' else 'completed' end, + stage = case when p_action = 'retry' then 'queued after recovery' else 'superseded during recovery' end, + progress = case when p_action = 'retry' then 0 else 100 end, + attempt_count = case when p_action = 'retry' then 0 else attempt_count end, + next_run_at = case when p_action = 'retry' then v_now else null end, + completed_at = case when p_action = 'retry' then null else v_now end, + error_message = null, locked_by = null, locked_at = null, + lease_token = null, lease_expected_generation_id = null, lease_write_generation_id = null, + lease_authority_digest = null, lease_expires_at = null, lease_duration_seconds = null + where id = p_job_id; + return jsonb_build_object('ok',true,'action',p_action,'artifacts_preserved',true); +end $$; +alter function public.recover_ingestion_job(uuid,text,jsonb) owner to postgres; +revoke all on function public.recover_ingestion_job(uuid,text,jsonb) from public, anon, authenticated; +grant execute on function public.recover_ingestion_job(uuid,text,jsonb) to service_role; + +-- F13: one statement snapshot of the complete effective owner/public/document scope. +-- Per-row fixed-size hashes bound aggregate memory without returning document metadata. +create function public.rag_cache_scope_revision_v1(p_owner_id uuid, p_include_public boolean, p_document_ids uuid[] default null) +returns text language sql stable security invoker set search_path = '' as $$ + select 'rag-cache-scope-v1:' || pg_catalog.md5(coalesce(pg_catalog.string_agg(row_hash, '' order by id),'')) + from ( + select d.id, pg_catalog.md5(pg_catalog.jsonb_build_array(d.id,d.owner_id,d.status,d.updated_at,d.metadata,d.index_generation_id)::text) as row_hash + from public.documents d + where (d.owner_id = p_owner_id or (coalesce(p_include_public,false) and d.owner_id is null)) + and (coalesce(cardinality(p_document_ids),0) = 0 or d.id = any(p_document_ids)) + ) revision +$$; +alter function public.rag_cache_scope_revision_v1(uuid,boolean,uuid[]) owner to postgres; +revoke all on function public.rag_cache_scope_revision_v1(uuid,boolean,uuid[]) from public, anon, authenticated; +grant execute on function public.rag_cache_scope_revision_v1(uuid,boolean,uuid[]) to service_role; + +-- HY05: team movement and removal of its whole-team authority commit together. +create function public.work_revoke_supervisors_on_team_move() returns trigger +language plpgsql security invoker set search_path = '' as $$ +begin + if new.hospital_id is distinct from old.hospital_id then + update public.work_role_grants set revoked_at = now(), revoked_by = new.linked_by + where role = 'supervisor' and service_id = new.service_id and subject_user_id is null and revoked_at is null; + end if; + return new; +end $$; +alter function public.work_revoke_supervisors_on_team_move() owner to postgres; +revoke all on function public.work_revoke_supervisors_on_team_move() from public, anon, authenticated; +grant execute on function public.work_revoke_supervisors_on_team_move() to service_role; +create trigger work_team_move_revokes_supervisors after update of hospital_id on public.work_hospital_teams +for each row execute function public.work_revoke_supervisors_on_team_move(); + +-- A grant admitted against an old hospital must not race the move. Both paths +-- lock the team before writing authority. Revocation itself needs no team lookup. +create function public.work_guard_whole_team_supervisor() returns trigger +language plpgsql security invoker set search_path = '' as $$ +begin + if new.role = 'supervisor' and new.service_id is not null and new.revoked_at is null then + perform 1 from public.work_hospital_teams where service_id = new.service_id and hospital_id = new.hospital_id for share; + if not found then raise exception 'work_team_changed'; end if; + end if; + return new; +end $$; +alter function public.work_guard_whole_team_supervisor() owner to postgres; +revoke all on function public.work_guard_whole_team_supervisor() from public, anon, authenticated; +grant execute on function public.work_guard_whole_team_supervisor() to service_role; +create trigger work_whole_team_supervisor_scope before insert or update on public.work_role_grants +for each row execute function public.work_guard_whole_team_supervisor(); + +-- Versioned application entry point: refuse app-before-SQL instead of moving +-- without the transactional revocation trigger. Old callers remain trigger-protected. +create function public.work_move_team_v1(p_actor_id uuid,p_service_id uuid,p_expected_hospital_id uuid,p_to_hospital_id uuid) +returns uuid language plpgsql security definer set search_path = '' as $$ +begin + if not exists(select 1 from auth.users where id = p_actor_id + and raw_app_meta_data->>'site_role' = 'administrator') then + raise exception 'work_role_denied'; + end if; + perform 1 from public.work_hospitals where id = p_to_hospital_id and archived_at is null for share; + if not found then raise exception 'work_hospital_unavailable'; end if; + if p_expected_hospital_id is not distinct from p_to_hospital_id then raise exception 'work_team_changed'; end if; + update public.work_hospital_teams set hospital_id = p_to_hospital_id, linked_by = p_actor_id, linked_at = now() + where service_id = p_service_id and hospital_id = p_expected_hospital_id; + if not found then raise exception 'work_team_changed'; end if; + return p_expected_hospital_id; +end $$; +alter function public.work_move_team_v1(uuid,uuid,uuid,uuid) owner to postgres; +revoke all on function public.work_move_team_v1(uuid,uuid,uuid,uuid) from public, anon, authenticated; +grant execute on function public.work_move_team_v1(uuid,uuid,uuid,uuid) to service_role; + +-- F14 / HY06: pairing authority is locked before authorization and child writes. +create or replace function public.teaching_depth_command(p_actor_id uuid, p_service_id uuid, p_action text, p_payload jsonb default '{}') returns jsonb +language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$ +declare + v_service public.on_call_services; + v_role text; + v_occ public.teaching_occurrences; + v_pairing public.teaching_supervision_pairings; + v_entry public.teaching_supervision_entries; + v_note public.teaching_supervision_notes; + v_readiness public.teaching_readiness; + v_id uuid; + v_registrar uuid; + v_supervisor uuid; + v_ids uuid[]; + v_topics text[]; + v_starts date; + v_ends date; + v_date date; + v_target numeric; + v_text text; + v_minutes integer; + v_useful integer; + v_count integer; + v_result jsonb; + v_row jsonb; + v_leaver boolean := false; +begin + if p_actor_id is null then raise exception 'teaching_auth_required'; end if; + if p_action is null or p_payload is null or jsonb_typeof(p_payload) <> 'object' or p_service_id is null + or p_action not in ('pairing.save','pairing.reassign','supervision.log','supervision.confirm','supervision.note', + 'supervision.note.confirm','supervision.read','supervision.target.set','readiness.set','readiness.deid.confirm', + 'feedback.submit','feedback.totals','import.commit') then + raise exception 'teaching_invalid_request'; + end if; + select * into v_service from public.on_call_services where id = p_service_id for share; + if not found then raise exception 'teaching_access_denied'; end if; + v_role := public.teaching_member_role(p_service_id, p_actor_id); + if v_role is null then + -- A leaver keeps read-only access to their own supervision entries (as registrar) for 90 days + -- from the shared revoke, as logbook.read keeps their attendance (spec §9). Nothing else. + v_leaver := p_action = 'supervision.read' and exists (select 1 from public.on_call_service_members m + where m.service_id = p_service_id and m.user_id = p_actor_id + and m.revoked_at is not null and m.revoked_at > now() - interval '90 days'); + if not v_leaver then raise exception 'teaching_access_denied'; end if; + end if; + if p_action not in ('supervision.read','feedback.totals') and not (v_service.verified_at is not null or v_service.is_demo) then + raise exception 'teaching_team_unverified'; + end if; + if p_action in ('pairing.save','pairing.reassign','import.commit') then + perform pg_advisory_xact_lock(hashtextextended(p_service_id::text, 74818)); + end if; + + if p_action = 'pairing.save' then + v_id := public.teaching_uuid_arg(p_payload, 'pairingId'); + v_registrar := public.teaching_uuid_arg(p_payload, 'registrarId'); + v_supervisor := public.teaching_uuid_arg(p_payload, 'supervisorId'); + v_starts := public.teaching_date_arg(p_payload, 'startsOn'); + v_ends := public.teaching_date_arg(p_payload, 'endsOn'); + -- The hours target is the registrar's own (supervision.target.set); an organiser never sets it. + if p_payload ? 'targetHours' then raise exception 'teaching_invalid_request'; end if; + -- Organisers pair other people. A pairing that includes the actor needs a Teaching admin. + if not (v_role = 'admin' or (v_role = 'organiser' and p_actor_id <> v_registrar and p_actor_id <> v_supervisor)) then + raise exception 'teaching_role_denied'; + end if; + if v_registrar is null or v_supervisor is null or v_registrar = v_supervisor + or v_starts is null or v_ends is null or v_ends < v_starts or v_ends > v_starts + 731 + or not public.service_member_active(p_service_id, v_registrar) + or not public.service_member_active(p_service_id, v_supervisor) then + raise exception 'teaching_invalid_request'; + end if; + if v_id is null then + if (select count(*) from public.teaching_supervision_pairings where service_id = p_service_id) >= 5000 then + raise exception 'teaching_limit'; + end if; + insert into public.teaching_supervision_pairings(service_id, registrar_id, supervisor_id, starts_on, ends_on, created_by) + values (p_service_id, v_registrar, v_supervisor, v_starts, v_ends, p_actor_id) + returning * into v_pairing; + else + select * into v_pairing from public.teaching_supervision_pairings where id = v_id and service_id = p_service_id for update; + if not found then raise exception 'teaching_not_found'; end if; + -- Once a pairing has entries, only its end date and target can change; the supervisor + -- changes through pairing.reassign. + if exists (select 1 from public.teaching_supervision_entries e where e.pairing_id = v_pairing.id) + and (v_pairing.registrar_id is distinct from v_registrar or v_pairing.supervisor_id is distinct from v_supervisor + or v_pairing.starts_on <> v_starts) then + raise exception 'teaching_invalid_request'; + end if; + update public.teaching_supervision_pairings set registrar_id = v_registrar, supervisor_id = v_supervisor, + starts_on = v_starts, ends_on = v_ends, updated_at = now() + where id = v_pairing.id returning * into v_pairing; + end if; + perform public.teaching_audit(p_service_id, p_actor_id, 'pairing.save', v_pairing.id); + return jsonb_build_object('pairingId', v_pairing.id); + + elsif p_action = 'pairing.reassign' then + v_id := public.teaching_uuid_arg(p_payload, 'pairingId'); + v_supervisor := public.teaching_uuid_arg(p_payload, 'supervisorId'); + select * into v_pairing from public.teaching_supervision_pairings where id = v_id and service_id = p_service_id for update; + if not found then raise exception 'teaching_not_found'; end if; + if not (v_role = 'admin' or (v_role = 'organiser' and p_actor_id is distinct from v_pairing.registrar_id and p_actor_id <> v_supervisor)) then + raise exception 'teaching_role_denied'; + end if; + if v_supervisor is null or v_supervisor is not distinct from v_pairing.registrar_id + or not public.service_member_active(p_service_id, v_supervisor) then + raise exception 'teaching_invalid_request'; + end if; + -- Confirmed entries keep who confirmed them; pending entries and notes now wait for the new + -- supervisor, because confirmation always checks the pairing's current supervisor. + update public.teaching_supervision_pairings set supervisor_id = v_supervisor, updated_at = now() where id = v_pairing.id; + select count(*) into v_count from public.teaching_supervision_entries where pairing_id = v_pairing.id and status = 'pending'; + perform public.teaching_audit(p_service_id, p_actor_id, 'pairing.reassign', v_pairing.id); + return jsonb_build_object('pairingId', v_pairing.id, 'pendingMoved', v_count); + + elsif p_action = 'supervision.target.set' then + -- The registrar's own hours target for this pairing: nullable, no default, and progress is shown + -- only once it is set. Only the pairing's registrar sets or clears it (targetHours null). + v_id := public.teaching_uuid_arg(p_payload, 'pairingId'); + select * into v_pairing from public.teaching_supervision_pairings where id = v_id and service_id = p_service_id for update; + if not found then raise exception 'teaching_not_found'; end if; + if v_pairing.registrar_id is distinct from p_actor_id then raise exception 'teaching_role_denied'; end if; + if not (p_payload ? 'targetHours') then raise exception 'teaching_invalid_request'; end if; + if p_payload->'targetHours' <> 'null'::jsonb then + if jsonb_typeof(p_payload->'targetHours') <> 'number' or (p_payload->>'targetHours') !~ '^[0-9]{1,3}(\.[0-9])?$' then + raise exception 'teaching_invalid_request'; + end if; + v_target := (p_payload->>'targetHours')::numeric; + if v_target not between 1 and 500 then raise exception 'teaching_invalid_request'; end if; + end if; + update public.teaching_supervision_pairings set target_hours = v_target, updated_at = now() where id = v_pairing.id; + perform public.teaching_audit(p_service_id, p_actor_id, 'supervision.target.set', v_pairing.id); + return jsonb_build_object('pairingId', v_pairing.id, 'targetHours', v_target); + + elsif p_action = 'supervision.log' then + v_id := public.teaching_uuid_arg(p_payload, 'pairingId'); + select * into v_pairing from public.teaching_supervision_pairings where id = v_id and service_id = p_service_id for update; + if not found then raise exception 'teaching_not_found'; end if; + if v_pairing.registrar_id is distinct from p_actor_id then raise exception 'teaching_role_denied'; end if; + v_date := public.teaching_date_arg(p_payload, 'date'); + v_minutes := public.teaching_int_arg(p_payload, 'minutes'); + v_text := public.teaching_text_arg(p_payload, 'type'); + v_topics := public.teaching_text_array_arg(p_payload, 'topics', 5); + if v_date is null or v_date < v_pairing.starts_on or v_date > v_pairing.ends_on + or v_date > (now() at time zone 'Australia/Perth')::date + or v_minutes is null or v_minutes not between 15 and 240 or v_minutes % 15 <> 0 + or v_text is null or v_text not in ('individual','group') + -- Topics are optional: none to five. + or cardinality(v_topics) not between 0 and 5 + or not (v_topics <@ array['case_review','risk','psychotherapy','formulation','medication','mha_legal','teaching_skills','career','exam_prep','wellbeing','other']::text[]) then + raise exception 'teaching_invalid_request'; + end if; + if (select count(*) from public.teaching_supervision_entries where pairing_id = v_pairing.id) >= 1000 then + raise exception 'teaching_limit'; + end if; + insert into public.teaching_supervision_entries(pairing_id, service_id, session_date, minutes, type, topics, logged_by) + values (v_pairing.id, p_service_id, v_date, v_minutes, v_text, v_topics, p_actor_id) + returning * into v_entry; + perform public.teaching_audit(p_service_id, p_actor_id, 'supervision.log', v_entry.id); + return jsonb_build_object('entryId', v_entry.id); + + elsif p_action = 'supervision.confirm' then + v_ids := public.teaching_uuid_array_arg(p_payload, 'entryIds', 20); + if cardinality(v_ids) = 0 then raise exception 'teaching_invalid_request'; end if; + -- Parent before child, sorted for multi-pairing batches. Authorization is checked + -- after any wait, so confirmation linearizes with reassignment; a confirmation + -- committed first remains historical evidence under its original supervisor. + perform p.id from public.teaching_supervision_pairings p + where p.service_id = p_service_id and p.id in ( + select e.pairing_id from public.teaching_supervision_entries e + where e.id = any(v_ids) and e.service_id = p_service_id) + order by p.id for update; + perform e.id from public.teaching_supervision_entries e + where e.id = any(v_ids) and e.service_id = p_service_id order by e.id for update; + -- All or nothing: every entry must be pending and belong to a pairing the actor now supervises. + if (select count(*) from public.teaching_supervision_entries e + join public.teaching_supervision_pairings p on p.id = e.pairing_id + where e.id = any(v_ids) and e.service_id = p_service_id and e.status = 'pending' and p.supervisor_id = p_actor_id) + <> cardinality(v_ids) then + raise exception 'teaching_not_found'; + end if; + update public.teaching_supervision_entries set status = 'confirmed', confirmed_by = p_actor_id, confirmed_at = now(), locked = true + where id = any(v_ids); + insert into public.teaching_audit_events(service_id, actor_id, action, subject_id) + select p_service_id, p_actor_id, 'supervision.confirm', u from unnest(v_ids) as u; + return jsonb_build_object('confirmed', cardinality(v_ids)); + + elsif p_action = 'supervision.note' then + v_id := public.teaching_uuid_arg(p_payload, 'entryId'); + perform p.id from public.teaching_supervision_pairings p + join public.teaching_supervision_entries e on e.pairing_id = p.id + where e.id = v_id and e.service_id = p_service_id for update of p; + select e.* into v_entry from public.teaching_supervision_entries e + join public.teaching_supervision_pairings p on p.id = e.pairing_id + where e.id = v_id and e.service_id = p_service_id and p.registrar_id = p_actor_id; + if not found then raise exception 'teaching_not_found'; end if; + v_text := public.teaching_text_arg(p_payload, 'reason'); + if v_text is null or not public.teaching_valid_correction(v_text, p_payload->'correctedValue') then + raise exception 'teaching_invalid_request'; + end if; + if v_text = 'wrong_date' then + perform public.teaching_date_arg(p_payload->'correctedValue', 'date'); + end if; + if (select count(*) from public.teaching_supervision_notes where entry_id = v_entry.id and confirmed_at is null) >= 5 then + raise exception 'teaching_limit'; + end if; + insert into public.teaching_supervision_notes(entry_id, service_id, reason, corrected_value, created_by) + values (v_entry.id, p_service_id, v_text, p_payload->'correctedValue', p_actor_id) + returning * into v_note; + perform public.teaching_audit(p_service_id, p_actor_id, 'supervision.note', v_note.id); + return jsonb_build_object('noteId', v_note.id); + + elsif p_action = 'supervision.note.confirm' then + v_id := public.teaching_uuid_arg(p_payload, 'noteId'); + perform p.id from public.teaching_supervision_pairings p + join public.teaching_supervision_entries e on e.pairing_id = p.id + join public.teaching_supervision_notes n on n.entry_id = e.id + where n.id = v_id and n.service_id = p_service_id for update of p; + select n.* into v_note from public.teaching_supervision_notes n + join public.teaching_supervision_entries e on e.id = n.entry_id + join public.teaching_supervision_pairings p on p.id = e.pairing_id + where n.id = v_id and n.service_id = p_service_id and p.supervisor_id = p_actor_id and n.confirmed_at is null + for update of n; + if not found then raise exception 'teaching_not_found'; end if; + update public.teaching_supervision_notes set confirmed_by = p_actor_id, confirmed_at = now() where id = v_note.id; + perform public.teaching_audit(p_service_id, p_actor_id, 'supervision.note.confirm', v_note.id); + return jsonb_build_object('noteId', v_note.id); + + elsif p_action = 'supervision.read' then + -- The registrar and supervisor of a pairing see its entries; an organiser sees totals and + -- status only (never topics). Reading anyone else's pairing is audited. A leaver (v_leaver) + -- sees only the pairings where they are the registrar, and reads their own record unaudited. + v_id := public.teaching_uuid_arg(p_payload, 'pairingId'); + if v_id is not null and not exists ( + select 1 from public.teaching_supervision_pairings p where p.id = v_id and p.service_id = p_service_id + and (p.registrar_id = p_actor_id or (not v_leaver and (p.supervisor_id = p_actor_id or v_role = 'organiser')))) then + raise exception 'teaching_not_found'; + end if; + if v_id is null and v_role = 'organiser' then + -- An organiser's whole-team read (Logbook, Today): one service-level row per call. + perform public.teaching_audit(p_service_id, p_actor_id, 'supervision.read', null); + elsif not v_leaver then + insert into public.teaching_audit_events(service_id, actor_id, action, subject_id) + select p_service_id, p_actor_id, 'supervision.read', p.id + from public.teaching_supervision_pairings p + where p.service_id = p_service_id and (v_id is null or p.id = v_id) + and p.registrar_id is distinct from p_actor_id + and (p.supervisor_id = p_actor_id or v_role = 'organiser'); + end if; + select jsonb_build_object('pairings', coalesce(jsonb_agg(jsonb_build_object( + 'pairingId', p.id, + 'access', case when p.registrar_id = p_actor_id then 'registrar' when p.supervisor_id = p_actor_id then 'supervisor' else 'organiser' end, + 'registrarName', case when p.registrar_id is null then 'Former member' else coalesce((select coalesce(m.display_name, 'Member') + from public.on_call_service_members m where m.service_id = p.service_id and m.user_id = p.registrar_id), 'Former member') end, + 'supervisorName', case when p.supervisor_id is null then 'Former member' else coalesce((select coalesce(m.display_name, 'Member') + from public.on_call_service_members m where m.service_id = p.service_id and m.user_id = p.supervisor_id), 'Former member') end, + 'startsOn', p.starts_on, 'endsOn', p.ends_on, + 'targetHours', case when p.registrar_id = p_actor_id or p.supervisor_id = p_actor_id then p.target_hours else null end, + 'confirmedMinutes', (select coalesce(sum(e.minutes), 0) from public.teaching_supervision_entries e where e.pairing_id = p.id and e.status = 'confirmed'), + 'pendingMinutes', (select coalesce(sum(e.minutes), 0) from public.teaching_supervision_entries e where e.pairing_id = p.id and e.status = 'pending'), + 'pendingCount', (select count(*) from public.teaching_supervision_entries e where e.pairing_id = p.id and e.status = 'pending'), + 'oldestPendingAt', (select min(e.created_at) from public.teaching_supervision_entries e where e.pairing_id = p.id and e.status = 'pending'), + 'entries', case when p.registrar_id = p_actor_id or p.supervisor_id = p_actor_id then coalesce((select jsonb_agg(jsonb_build_object( + 'entryId', e.id, 'date', e.session_date, 'minutes', e.minutes, 'type', e.type, 'topics', to_jsonb(e.topics), + 'status', e.status, 'confirmedAt', e.confirmed_at, + 'confirmedByName', case when e.confirmed_at is null then null when e.confirmed_by is null then 'Former member' else coalesce( + (select coalesce(m.display_name, 'Member') from public.on_call_service_members m where m.service_id = e.service_id and m.user_id = e.confirmed_by), + 'Former member') end, + 'notes', coalesce((select jsonb_agg(jsonb_build_object('noteId', n.id, 'reason', n.reason, 'correctedValue', n.corrected_value, + 'createdAt', n.created_at, 'confirmedAt', n.confirmed_at) order by n.created_at) + from public.teaching_supervision_notes n where n.entry_id = e.id), '[]'::jsonb) + ) order by e.session_date desc, e.created_at desc) from public.teaching_supervision_entries e where e.pairing_id = p.id), '[]'::jsonb) + else null end + ) order by p.starts_on desc, p.id), '[]'::jsonb)) + into v_result + from public.teaching_supervision_pairings p + where p.service_id = p_service_id and (v_id is null or p.id = v_id) + and (p.registrar_id = p_actor_id or (not v_leaver and (p.supervisor_id = p_actor_id or v_role = 'organiser'))); + return v_result; + + elsif p_action in ('readiness.set','readiness.deid.confirm') then + v_id := public.teaching_uuid_arg(p_payload, 'occurrenceId'); + select * into v_occ from public.teaching_occurrences where id = v_id and service_id = p_service_id; + if not found then raise exception 'teaching_not_found'; end if; + if v_occ.presenter_id is distinct from p_actor_id then raise exception 'teaching_role_denied'; end if; + insert into public.teaching_readiness(occurrence_id, service_id) values (v_occ.id, p_service_id) + on conflict (occurrence_id) do nothing; + if p_action = 'readiness.set' then + v_text := public.teaching_text_arg(p_payload, 'item'); + if v_text is null or v_text not in ('reading_list','aims','slides_link','room') + or jsonb_typeof(p_payload->'done') is distinct from 'boolean' then + raise exception 'teaching_invalid_request'; + end if; + update public.teaching_readiness set + items = case when (p_payload->>'done')::boolean + then array(select distinct i from unnest(array_append(items, v_text)) as i order by i) + else array_remove(items, v_text) end, + updated_at = now() + where occurrence_id = v_occ.id returning * into v_readiness; + else + update public.teaching_readiness set deid_confirmed_at = now(), deid_confirmed_by = p_actor_id, updated_at = now() + where occurrence_id = v_occ.id returning * into v_readiness; + end if; + perform public.teaching_audit(p_service_id, p_actor_id, p_action, v_occ.id); + return jsonb_build_object('items', to_jsonb(v_readiness.items), 'deidConfirmedAt', v_readiness.deid_confirmed_at); + + elsif p_action = 'feedback.submit' then + v_id := public.teaching_uuid_arg(p_payload, 'occurrenceId'); + select * into v_occ from public.teaching_occurrences where id = v_id and service_id = p_service_id; + if not found then raise exception 'teaching_not_found'; end if; + if not exists (select 1 from public.teaching_attendance a where a.occurrence_id = v_occ.id and a.user_id = p_actor_id) then + raise exception 'teaching_not_attended'; + end if; + if v_occ.status = 'cancelled' or now() < v_occ.ends_at or now() > v_occ.ends_at + interval '7 days' then + raise exception 'teaching_window_closed'; + end if; + v_useful := public.teaching_int_arg(p_payload, 'useful'); + v_text := public.teaching_text_arg(p_payload, 'pace'); + if v_useful is null or v_useful not between 1 and 5 or v_text is null or v_text not in ('slow','right','fast') then + raise exception 'teaching_invalid_request'; + end if; + -- One-way "already answered" marker. No responder id is stored with feedback anywhere. + insert into public.teaching_feedback_replied(occurrence_id, reply_marker) + values (v_occ.id, encode(extensions.digest(convert_to('teaching-feedback:' || v_occ.id::text || ':' || p_actor_id::text, 'UTF8'), 'sha256'), 'hex')) + on conflict (occurrence_id, reply_marker) do nothing; + if not found then raise exception 'teaching_limit'; end if; + insert into public.teaching_feedback_answers(occurrence_id, service_id, useful, pace) + values (v_occ.id, p_service_id, v_useful, v_text); + -- No actor on this audit row: nothing may tie a person to the moment an answer was written. + perform public.teaching_audit(p_service_id, null, 'feedback.submit', v_occ.id); + return '{}'::jsonb; + + elsif p_action = 'import.commit' then + -- A term from a spreadsheet, all or nothing: every row is a new series through the same save as + -- series.save, inside this one transaction, so one refused row rolls back every row before it. + -- Spec §4 gives import to organisers and admins. + if v_role not in ('organiser','admin') then raise exception 'teaching_role_denied'; end if; + if jsonb_typeof(p_payload->'rows') is distinct from 'array' + or jsonb_array_length(p_payload->'rows') not between 1 and 200 then + raise exception 'teaching_invalid_request'; + end if; + v_count := 0; + for v_row in select r.value from jsonb_array_elements(p_payload->'rows') as r(value) loop + if jsonb_typeof(v_row) is distinct from 'object' or v_row ? 'seriesId' then + raise exception 'teaching_invalid_request'; + end if; + v_result := public.teaching_series_save(p_actor_id, p_service_id, v_row); + perform public.teaching_audit(p_service_id, p_actor_id, 'series.save', public.teaching_uuid_arg(v_result, 'seriesId')); + v_count := v_count + public.teaching_int_arg(v_result, 'occurrences'); + end loop; + perform public.teaching_audit(p_service_id, p_actor_id, 'import.commit', null); + return jsonb_build_object('series', jsonb_array_length(p_payload->'rows'), 'occurrences', v_count); + + elsif p_action = 'feedback.totals' then + v_id := public.teaching_uuid_arg(p_payload, 'occurrenceId'); + select * into v_occ from public.teaching_occurrences where id = v_id and service_id = p_service_id; + if not found then raise exception 'teaching_not_found'; end if; + if (v_occ.presenter_id = p_actor_id or v_role = 'organiser') is not true then raise exception 'teaching_role_denied'; end if; + select count(*) into v_count from public.teaching_feedback_answers where occurrence_id = v_occ.id; + if now() < v_occ.ends_at + interval '7 days' or v_count < 5 then + return jsonb_build_object('released', false); + end if; + perform public.teaching_audit(p_service_id, p_actor_id, 'feedback.totals', v_occ.id); + return jsonb_build_object( + 'released', true, + 'replies', v_count, + 'useful', (select jsonb_build_object('1', count(*) filter (where useful = 1), '2', count(*) filter (where useful = 2), + '3', count(*) filter (where useful = 3), '4', count(*) filter (where useful = 4), '5', count(*) filter (where useful = 5)) + from public.teaching_feedback_answers where occurrence_id = v_occ.id), + 'pace', (select jsonb_build_object('slow', count(*) filter (where pace = 'slow'), 'right', count(*) filter (where pace = 'right'), + 'fast', count(*) filter (where pace = 'fast')) + from public.teaching_feedback_answers where occurrence_id = v_occ.id) + ); + end if; + + raise exception 'teaching_invalid_request'; +end $$; +alter function public.teaching_depth_command(uuid,uuid,text,jsonb) owner to postgres; +revoke all on function public.teaching_depth_command(uuid,uuid,text,jsonb) from public, anon, authenticated; +grant execute on function public.teaching_depth_command(uuid,uuid,text,jsonb) to service_role; diff --git a/tests/account-request-scope.dom.test.tsx b/tests/account-request-scope.dom.test.tsx new file mode 100644 index 0000000000..d316040790 --- /dev/null +++ b/tests/account-request-scope.dom.test.tsx @@ -0,0 +1,121 @@ +/** @vitest-environment jsdom */ + +import { act, renderHook } from "@testing-library/react"; +import { useEffect } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useAccountRequestScope } from "@/components/use-account-request-scope"; +import { createAuthRequestLifecycle } from "@/lib/auth-request-lifecycle"; +import type { useAuthSession } from "@/lib/supabase/client"; + +type Auth = ReturnType; +const state = vi.hoisted(() => ({ auth: null as Auth | null })); +vi.mock("@/lib/supabase/client", () => ({ useAuthSession: () => state.auth })); + +let lifecycle: ReturnType; + +beforeEach(() => { + lifecycle = createAuthRequestLifecycle(7); + // Keep every field of the real AuthProvider contract, including its stable, + // imperative epoch methods. No provider, cookie or network request is used. + state.auth = { + client: null, + session: { + access_token: "synthetic-token-1", + refresh_token: "synthetic-refresh", + expires_in: 3600, + token_type: "bearer", + user: { + id: "synthetic-A", + app_metadata: {}, + user_metadata: {}, + aud: "authenticated", + created_at: "2020-01-01T00:00:00Z", + }, + }, + status: "authenticated", + error: null, + notice: null, + isConfigured: true, + authorizationHeader: { Authorization: "Bearer synthetic-token-1" }, + authEpoch: lifecycle.currentEpoch(), + registerAuthRequest: lifecycle.register, + isAuthEpochCurrent: lifecycle.isCurrent, + signInWithEmail: vi.fn(async () => undefined), + signInWithPassword: vi.fn(async () => undefined), + signUpWithPassword: vi.fn(async () => undefined), + signInWithOAuth: vi.fn(async () => undefined), + signOut: vi.fn(async () => undefined), + markSessionExpired: vi.fn(), + } satisfies Auth; +}); + +describe("account request scope with AuthProvider's epoch contract", () => { + it("rejects captured work when the real lifecycle invalidates before React publishes another auth value", () => { + const view = renderHook(useAccountRequestScope); + const captured = view.result.current.isCurrent; + const publishedAuth = state.auth!; + const controller = new AbortController(); + const registration = publishedAuth.registerAuthRequest(controller); + expect(captured()).toBe(true); + + // AuthProvider invalidates imperatively before its queued React render. + // Deliberately do not mutate auth fields, replace the object or rerender. + lifecycle.invalidate(); + expect(state.auth).toBe(publishedAuth); + expect(state.auth!.authEpoch).toBe(7); + expect(controller.signal.aborted).toBe(true); + expect(captured()).toBe(false); + registration.release(); + }); + + it("rejects retained work after unmount even while the auth epoch is still current", () => { + const view = renderHook(useAccountRequestScope); + const captured = view.result.current.isCurrent; + expect(captured()).toBe(true); + view.unmount(); + expect(state.auth!.isAuthEpochCurrent(state.auth!.authEpoch)).toBe(true); + expect(captured()).toBe(false); + }); + + it("preserves the scope, dependent read and queued callback across same-owner token refresh", async () => { + const dependentRead = vi.fn(); + const queuedWrite = vi.fn(); + const view = renderHook(() => { + const scope = useAccountRequestScope(); + useEffect(() => { + dependentRead(); + }, [scope.isCurrent]); + return scope; + }); + const captured = view.result.current.isCurrent; + const scope = view.result.current.scope; + let release!: () => void; + const barrier = new Promise((resolve) => { + release = resolve; + }); + const queued = barrier.then(() => { + if (captured()) queuedWrite(); + }); + + const previous = state.auth!; + act(() => { + state.auth = { + ...previous, + authorizationHeader: { Authorization: "Bearer synthetic-token-2" }, + session: { ...previous.session!, access_token: "synthetic-token-2" }, + }; + view.rerender(); + }); + expect(state.auth).not.toBe(previous); + expect(view.result.current.scope).toBe(scope); + expect(view.result.current.isCurrent).toBe(captured); + expect(captured()).toBe(true); + expect(dependentRead).toHaveBeenCalledTimes(1); + await act(async () => { + release(); + await queued; + }); + expect(queuedWrite).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/admin-auth-transition.dom.test.tsx b/tests/admin-auth-transition.dom.test.tsx new file mode 100644 index 0000000000..4669b1a20c --- /dev/null +++ b/tests/admin-auth-transition.dom.test.tsx @@ -0,0 +1,95 @@ +/** @vitest-environment jsdom */ +// Existing B, mounted components and immutable auth values. All requests are synthetic. +import { act, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { AdminNewJobSharing } from "@/components/admin/new-job/admin-new-job-sharing"; +import { useJuniorRosterLeave } from "@/components/admin/junior/use-roster-leave"; + +const state = vi.hoisted(() => ({ + auth: { status: "authenticated", authEpoch: 1, session: { user: { id: "synthetic-A" } } }, +})); +vi.mock("@/lib/supabase/client", () => ({ useAuthSession: () => state.auth })); +function switchAccount() { + state.auth = { ...state.auth, authEpoch: state.auth.authEpoch + 1, session: { user: { id: "synthetic-B" } } }; +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +beforeEach(() => { + state.auth = { ...state.auth, authEpoch: state.auth.authEpoch + 1, session: { user: { id: "synthetic-A" } } }; +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("mounted Admin identity consumers", () => { + it("loads B leave instead of retaining A's approved absence", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ + leave: + state.auth.session.user.id === "synthetic-A" + ? [ + { + id: "synthetic-A-leave", + kind: "annual", + startsOn: "2026-11-01", + endsOn: "2026-11-03", + status: "approved", + }, + ] + : [], + }), + ), + ); + const view = renderHook(() => useJuniorRosterLeave(true)); + await waitFor(() => expect(view.result.current.status).toBe("ready")); + expect(view.result.current).toMatchObject({ leave: [{ id: "synthetic-A-leave" }] }); + act(() => { + switchAccount(); + view.rerender(); + }); + await waitFor(() => expect(view.result.current).toEqual({ status: "ready", leave: [] })); + }); + + it("reads B's sharing choice when the mounted account changes", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ share: state.auth.session.user.id === "synthetic-A" })), + ); + const view = render(); + await waitFor(() => expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "true")); + act(() => { + switchAccount(); + view.rerender(); + }); + await waitFor(() => expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "false")); + }); + + for (const status of [200, 500]) { + it(`drops A's delayed sharing save result (${status}) after B loads`, async () => { + const pending = deferred(); + vi.stubGlobal( + "fetch", + vi.fn((_input: unknown, init?: RequestInit) => + init?.method === "PUT" ? pending.promise : Promise.resolve(Response.json({ share: false })), + ), + ); + const view = render(); + fireEvent.click(await screen.findByRole("switch")); + act(() => { + switchAccount(); + view.rerender(); + }); + await act(async () => {}); + await act(async () => pending.resolve(Response.json({ share: true }, { status }))); + expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "false"); + expect(screen.getByRole("switch")).not.toBeDisabled(); + expect(screen.queryByRole("alert")).toBeNull(); + }); + } +}); diff --git a/tests/admin-new-job-page.dom.test.tsx b/tests/admin-new-job-page.dom.test.tsx index 402577578b..e75013620b 100644 --- a/tests/admin-new-job-page.dom.test.tsx +++ b/tests/admin-new-job-page.dom.test.tsx @@ -1,6 +1,6 @@ /** @vitest-environment jsdom */ -import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AdminNewJobPage } from "@/components/admin/admin-new-job-page"; @@ -65,6 +65,11 @@ const entryState = vi.hoisted(() => ({ retry: vi.fn(), })); +const identity = vi.hoisted(() => ({ + auth: { status: "authenticated", authEpoch: 1, session: { user: { id: "synthetic-A" } } }, +})); +vi.mock("@/lib/supabase/client", () => ({ useAuthSession: () => identity.auth })); + const cacheOnCallEntries = vi.fn(); vi.mock("@/lib/on-call/entry-store", () => ({ useOnCallEntries: () => entryState, @@ -344,3 +349,33 @@ describe("AdminNewJobPage credentials wallet", () => { expect(screen.queryByTestId("admin-credentials-wallet")).toBeNull(); }); }); + +describe("New job captured-account writes", () => { + for (const status of [200, 500]) { + it(`drops A PATCH completion (${status}) after the entry store resets for B`, async () => { + let resolve!: (response: Response) => void; + vi.spyOn(globalThis, "fetch").mockImplementation( + () => + new Promise((done) => { + resolve = done; + }), + ); + const view = render(); + fireEvent.click(screen.getByTestId(`admin-new-job-step-${loginOwn.slug}-checkbox`)); + // AuthProvider clears the real store before publishing the next immutable identity. + identity.auth = { + ...identity.auth, + authEpoch: identity.auth.authEpoch + 1, + session: { user: { id: "synthetic-B" } }, + }; + Object.assign(entryState, { entries: [], loading: false }); + view.rerender(); + await act(async () => + resolve(Response.json({ entry: { ...loginOwn, details: { category: "Logins", done: true } } }, { status })), + ); + expect(cacheOnCallEntries).not.toHaveBeenCalled(); + expect(screen.queryByTestId("admin-new-job-undo")).toBeNull(); + expect(screen.queryByTestId("admin-new-job-error")).toBeNull(); + }); + } +}); diff --git a/tests/admin-pay-history.dom.test.tsx b/tests/admin-pay-history.dom.test.tsx new file mode 100644 index 0000000000..fe96f4e66d --- /dev/null +++ b/tests/admin-pay-history.dom.test.tsx @@ -0,0 +1,176 @@ +/** @vitest-environment jsdom */ +// Regression from the audit: real component/hooks, intercepted APIs with the 21-day cutoff. +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +vi.mock("next/navigation", () => ({ + usePathname: () => "/admin/pay", + useSearchParams: () => new URLSearchParams(), + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), prefetch: vi.fn(), refresh: vi.fn() }), +})); +vi.mock("@/lib/supabase/client", () => ({ + useAuthSession: () => ({ + status: "authenticated", + authEpoch: 1, + session: { user: { id: "synthetic-A", created_at: "2020-01-01T00:00:00Z" } }, + }), + hasSupabaseAuthSession: () => true, +})); +vi.mock("@/components/account-data-provider", () => ({ + useAccountData: () => ({ isAuthenticated: true }), + useOptionalAccountData: () => ({ isAuthenticated: true }), +})); +vi.mock("@/lib/on-call/entry-store", async (importOriginal) => ({ + ...(await importOriginal()), + useOnCallEntries: () => ({ + entries: [], + loading: false, + isOffline: false, + loadError: null, + signedOut: false, + demoMode: false, + cachedAt: null, + retry: () => undefined, + }), +})); +import { AdminPayPage } from "@/components/work-screens/admin/pay-page"; +import { resetSharedGets } from "@/lib/shared-get"; +import { resetExampleDataForTests, setExampleDataOn } from "@/lib/example-data/store"; +import { forgetAdminPaperworkOnDevice } from "@/lib/work-screens/admin/paperwork-store"; +import { addDaysToDate } from "@/lib/roster/shifts/perth-time"; +const now = new Date("2026-10-17T01:00:00Z"); +const allShifts = ["2026-09-22", "2026-09-28"].map((date, index) => ({ + id: `synthetic-pay-${index}`, + startsAt: `${date}T00:00:00Z`, + endsAt: `${date}T08:00:00Z`, + title: "Synthetic shift", + location: null, + sourceUid: null, + kind: "day", + source: "import", + seriesId: null, + workplace: "Synthetic workplace", +})); +beforeEach(() => { + forgetAdminPaperworkOnDevice(); + window.localStorage.clear(); + resetExampleDataForTests(); + setExampleDataOn(false); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(now); + resetSharedGets(); +}); +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + resetSharedGets(); +}); +const teamId = "51000000-0000-4000-8000-000000000001"; + +function serve({ + anchor = null, + empty = false, + fail = () => false, + shifts = allShifts, +}: { + anchor?: string | null; + empty?: boolean; + fail?: () => boolean; + shifts?: typeof allShifts; +} = {}) { + const calls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(String(input), "http://offline.invalid"); + calls.push(url.pathname + url.search); + if (url.pathname === "/api/roster/shifts") { + const from = url.searchParams.get("from"); + if (from && fail()) return Response.json({ error: "temporarily unavailable" }, { status: 503 }); + const cutoff = from ? Date.parse(`${from}T00:00:00Z`) : now.getTime() - 21 * 24 * 60 * 60 * 1000; + const to = url.searchParams.get("to"); + const end = to ? Date.parse(`${addDaysToDate(to, 1)}T00:00:00Z`) : Infinity; + return Response.json({ + shifts: empty + ? [] + : shifts.filter((shift) => Date.parse(shift.endsAt) > cutoff && Date.parse(shift.startsAt) < end), + latestImport: null, + }); + } + if (url.pathname === "/api/roster/team") + return Response.json({ + teams: anchor + ? [{ serviceId: teamId, name: "Synthetic team", enabled: true, role: "member", grade: "registrar" }] + : [], + actorId: "synthetic-A", + }); + if (url.pathname === `/api/roster/team/${teamId}`) + return Response.json( + url.searchParams.get("what") === "overview" + ? { + service: { id: teamId, name: "Synthetic team" }, + me: { role: "member", grade: "registrar", rotationEndsOn: null }, + latestPublication: null, + seenLatest: true, + sites: [], + settings: { swapApproval: "auto_same_grade", rules: {}, rulesSource: null, payFortnightAnchor: anchor }, + } + : { assignments: [] }, + ); + if (url.pathname === "/api/roster/extra-time") return Response.json({ records: [] }); + return Response.json({}); + }), + ); + return calls; +} + +it.each([ + { anchor: null, from: "2026-09-21", to: "2026-10-18", expected: "16", empty: false }, + { anchor: "2026-10-07", from: "2026-09-23", to: "2026-10-20", expected: "8", empty: false }, + { anchor: null, from: "2026-09-21", to: "2026-10-18", expected: "0", empty: true }, +])( + "fills the entire preceding fortnight with $expected hours (anchor $anchor, empty $empty)", + async ({ anchor, from, to, expected, empty }) => { + const calls = serve({ anchor, empty }); + render(); + await screen.findByTestId("admin-pay-hero"); + fireEvent.click(screen.getByTestId("admin-pay-check")); + // Default is Sep21-Oct4. Both known shifts are within it (16h); main GET only supplies Sep28 (8h). + await waitFor(() => expect(screen.getByTestId("admin-pay-rostered")).toHaveValue(expected)); + const readFrom = addDaysToDate(from, -1); + expect(calls.some((url) => url.includes(`/api/roster/shifts?from=${readFrom}`))).toBe(true); + if (anchor) expect(calls.some((url) => url.includes(`from=${readFrom}&to=${to}`))).toBe(true); + }, +); + +it("includes a midnight shift on the first local fortnight day without counting padded outside days or overlap twice", async () => { + const shift = (id: string, startsAt: string, endsAt: string) => ({ ...allShifts[0]!, id, startsAt, endsAt }); + serve({ + shifts: [ + // Previous fortnight starts21Sep00:00 AWST. Its first8h end exactly at the old UTC lower bound. + shift("first-local-day", "2026-09-20T16:00:00Z", "2026-09-21T00:00:00Z"), + // Padding may fetch this preceding-local-day shift, but it contributes no period hours. + shift("outside-period", "2026-09-20T00:00:00Z", "2026-09-20T08:00:00Z"), + // Both default and historical reads contain this row; count its8h once. + shift("overlap", "2026-09-26T08:00:00Z", "2026-09-26T16:00:00Z"), + ], + }); + render(); + await screen.findByTestId("admin-pay-hero"); + fireEvent.click(screen.getByTestId("admin-pay-check")); + await waitFor(() => expect(screen.getByTestId("admin-pay-rostered")).toHaveValue("16")); +}); + +it("does not prefill a partial total when older days fail, and recovers on retry", async () => { + let fail = true; + serve({ fail: () => fail }); + render(); + const warning = await screen.findByTestId("admin-pay-roster-failed"); + expect(warning).toHaveTextContent("Couldn't load your roster"); + expect(screen.queryByTestId("admin-pay-hero")).toBeNull(); + fail = false; + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + await screen.findByTestId("admin-pay-hero"); + fireEvent.click(screen.getByTestId("admin-pay-check")); + await waitFor(() => expect(screen.getByTestId("admin-pay-rostered")).toHaveValue("16")); +}); diff --git a/tests/agent-monitoring.test.ts b/tests/agent-monitoring.test.ts index 8dfe47c0e7..cdda0f80f0 100644 --- a/tests/agent-monitoring.test.ts +++ b/tests/agent-monitoring.test.ts @@ -1,8 +1,12 @@ +import { createRequire } from "node:module"; +import path from "node:path"; + import { afterEach, describe, expect, it, vi } from "vitest"; describe("Sentry agent monitoring gating", () => { afterEach(() => { vi.unstubAllEnvs(); + vi.doUnmock("@sentry/nextjs"); vi.resetModules(); }); @@ -37,3 +41,75 @@ describe("Sentry agent monitoring gating", () => { expect(() => setAgentConversationId("11111111-2222-4333-8444-555555555555")).not.toThrow(); }); }); + +// Load the installed package's actual conditional Edge entry: its export set +// differs from Node, so a whole-SDK mock could conceal this compatibility bug. +const require = createRequire(import.meta.url); +const sdkPackagePath = require.resolve("@sentry/nextjs/package.json"); +const sdkPackage = require(sdkPackagePath) as { exports: { ".": { edge: { require: string } } } }; +const edgeSdk = require( + path.resolve(path.dirname(sdkPackagePath), sdkPackage.exports["."].edge.require), +) as typeof import("@sentry/nextjs"); + +describe("agent conversation binding against installed SDK exports", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.doUnmock("@sentry/nextjs"); + vi.resetModules(); + }); + + async function enabledEdgeModule() { + vi.stubEnv("SENTRY_DSN", "https://synthetic@127.0.0.1/1"); + vi.stubEnv("SENTRY_TRACES_SAMPLE_RATE", "0.1"); + vi.doMock("@sentry/nextjs", () => edgeSdk); + return import("@/lib/observability/agent-monitoring"); + } + + // getClient is only used as an initialized-runtime gate by our wrapper. + // Bind an inert client object inside a temporary scope instead of Sentry.init: + // no integrations, DSN parsing, transport creation or delivery can start. + function withInertClient(callback: () => void) { + const isolationScope = edgeSdk.getIsolationScope(); + const previousConversation = isolationScope.getScopeData().conversationId; + isolationScope.setConversationId(undefined); + try { + edgeSdk.withScope((scope) => { + scope.setClient({} as NonNullable>); + callback(); + }); + } finally { + // Without init there is no runtime async-context isolation strategy. + isolationScope.setConversationId(previousConversation); + } + } + + it("binds the UUID through the real isolation-scope API when the runtime omits the convenience export", async () => { + const { isSentryAgentMonitoringEnabled, setAgentConversationId } = await enabledEdgeModule(); + const conversationId = "11111111-2222-4333-8444-555555555555"; + expect(edgeSdk.getClient()).toBeUndefined(); + expect(typeof edgeSdk.getIsolationScope().setConversationId).toBe("function"); + withInertClient(() => { + expect(isSentryAgentMonitoringEnabled()).toBe(true); + setAgentConversationId(conversationId); + expect(edgeSdk.getIsolationScope().getScopeData().conversationId).toBe(conversationId); + }); + expect(edgeSdk.getClient()).toBeUndefined(); + expect(edgeSdk.getIsolationScope().getScopeData().conversationId).toBeUndefined(); + }); + + it("rejects free text and malformed identifiers before touching the actual SDK scope", async () => { + const { setAgentConversationId } = await enabledEdgeModule(); + withInertClient(() => { + const scope = edgeSdk.getIsolationScope(); + for (const invalid of [ + "Synthetic clinical query", + "person@example.test", + "x".repeat(36), + "11111111-2222-4333-8444-555555555555 suffix", + ]) { + setAgentConversationId(invalid); + expect(scope.getScopeData().conversationId).toBeUndefined(); + } + }); + }); +}); diff --git a/tests/clinical-math-bounds.test.ts b/tests/clinical-math-bounds.test.ts index 1fc5074ce7..df2da6538d 100644 --- a/tests/clinical-math-bounds.test.ts +++ b/tests/clinical-math-bounds.test.ts @@ -4,7 +4,43 @@ import "./setup/jsdom.setup"; import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createElement as h } from "react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const syntheticRendererReview = vi.hoisted(() => ({ enabled: false })); + +// Exercise retained draft input ergonomics without changing real release metadata. +// This module-local test fixture is not a clinician's review or publication approval. +vi.mock("@/lib/calculators/clinical-math/governance.json", async (importOriginal) => { + const actual = await importOriginal<{ + default: { + releaseStatus: string; + clinicalReview: { + status: string; + reviewer: string | null; + reviewedOn: string | null; + approvalEvidence: string | null; + }; + }; + }>(); + return { + default: { + ...actual.default, + get releaseStatus() { + return syntheticRendererReview.enabled ? "available" : actual.default.releaseStatus; + }, + get clinicalReview() { + return syntheticRendererReview.enabled + ? { + status: "approved", + reviewer: "Synthetic renderer test fixture; no clinical approval", + reviewedOn: "2000-01-01", + approvalEvidence: "test-only synthetic renderer state", + } + : actual.default.clinicalReview; + }, + }, + }; +}); import { ClinicalMathPanel } from "@/components/calculators/clinical-math-panel"; import { PatientProfileProvider } from "@/components/clinical-dashboard/patient-profile-context"; @@ -14,6 +50,10 @@ import { calculateProportionalLithiumDose, evaluateLithiumLevel } from "@/lib/ca import { calculateQtc } from "@/lib/calculators/clinical-math/qtc"; import { calculateCkdEpi2021, calculateCockcroftGault } from "@/lib/calculators/clinical-math/renal"; +afterEach(() => { + syntheticRendererReview.enabled = false; +}); + describe("Clinical Math Physiological Bounds & Input Ergonomics", () => { describe("calculateQtc physiological bounds (P0-2)", () => { it("rejects QT interval below 150 ms (e.g. seconds entered instead of milliseconds) and above 800 ms", () => { @@ -390,7 +430,19 @@ describe("Clinical Math Physiological Bounds & Input Ergonomics", () => { expect(scrMgDlInput).toHaveAttribute("step", "0.01"); }); - it("renders ClinicalMathPanel bedside tabs for Renal, QTc, and Lithium calculators", async () => { + it("withholds bedside inputs and results under the actual pending clinical-review metadata", () => { + render(h(ClinicalMathPanel, { defaultOpen: true })); + expect(screen.getByTestId("clinical-math-release-hold")).toHaveTextContent("dedicated clinical validation"); + expect(screen.queryByRole("tablist")).not.toBeInTheDocument(); + expect(screen.queryByRole("spinbutton")).not.toBeInTheDocument(); + expect(screen.queryByTestId("clinical-math-renal-results")).not.toBeInTheDocument(); + expect(screen.queryByTestId("clinical-math-qtc-results")).not.toBeInTheDocument(); + expect(screen.queryByTestId("clinical-math-lithium-results")).not.toBeInTheDocument(); + expect(screen.queryByTestId("clinical-math-lithium-suggested-dose")).not.toBeInTheDocument(); + }); + + it("preserves dormant Renal, QTc and Lithium input ergonomics with an isolated synthetic review fixture", async () => { + syntheticRendererReview.enabled = true; const user = userEvent.setup(); render(h(ClinicalMathPanel, { defaultOpen: true })); diff --git a/tests/clinical-math-governance.dom.test.tsx b/tests/clinical-math-governance.dom.test.tsx new file mode 100644 index 0000000000..e5514b8d78 --- /dev/null +++ b/tests/clinical-math-governance.dom.test.tsx @@ -0,0 +1,30 @@ +/** @vitest-environment jsdom */ + +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { ClinicalMathPanel } from "@/components/calculators/clinical-math-panel"; +import governance from "@/lib/calculators/clinical-math/governance.json"; + +describe("physiological calculator clinical validation hold", () => { + it("refuses available metadata while the dedicated clinical review is still pending", () => { + const previous = governance.releaseStatus; + try { + governance.releaseStatus = "available"; + render(); + expect(screen.getByTestId("clinical-math-release-hold")).toBeInTheDocument(); + expect(screen.queryByRole("spinbutton")).not.toBeInTheDocument(); + } finally { + governance.releaseStatus = previous; + } + }); + + it.each(["renal", "qtc", "lithium"] as const)("withholds %s tools even when requested open", (defaultTab) => { + render(); + expect(screen.getByTestId("clinical-math-release-hold")).toHaveTextContent("dedicated clinical validation"); + expect(screen.queryByRole("spinbutton")).not.toBeInTheDocument(); + expect(screen.queryByTestId("clinical-math-lithium-suggested-dose")).not.toBeInTheDocument(); + expect(screen.queryByRole("tablist")).not.toBeInTheDocument(); + expect(screen.queryByText(/Suggested dose:|TOXICITY ALERT|Level status:|Risk:/)).not.toBeInTheDocument(); + }); +}); diff --git a/tests/clinical-math-governance.test.ts b/tests/clinical-math-governance.test.ts new file mode 100644 index 0000000000..1a7afde784 --- /dev/null +++ b/tests/clinical-math-governance.test.ts @@ -0,0 +1,55 @@ +import { createHash } from "node:crypto"; +import { readFileSync, readdirSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +import governance from "@/lib/calculators/clinical-math/governance.json"; + +describe("physiological calculator governance inventory", () => { + it("keeps every formula and management implementation held without inventing review", () => { + expect(governance.releaseStatus).toBe("held"); + expect(governance.clinicalReview).toEqual({ + status: "pending", + reviewer: null, + reviewedOn: null, + approvalEvidence: null, + }); + expect(governance.algorithms.map((entry) => entry.id)).toEqual([ + "cockcroft-gault", + "ckd-epi-2021", + "qtc", + "lithium-level", + "proportional-lithium-dose", + ]); + for (const algorithm of governance.algorithms) { + expect(algorithm.formula.length).toBeGreaterThan(30); + expect(algorithm.sourceLeads.length).toBeGreaterThan(0); + expect(algorithm.reviewRequired.length).toBeGreaterThan(0); + } + }); + + it("detects drift in all physiological math files and the display boundary", () => { + const directory = "src/lib/calculators/clinical-math/"; + const implementations = readdirSync(directory) + .filter((file) => file.endsWith(".ts")) + .map((file) => directory + file); + expect(Object.keys(governance.implementationPins).sort()).toEqual( + [...implementations, "src/components/calculators/clinical-math-panel.tsx"].sort(), + ); + for (const [path, pin] of Object.entries(governance.implementationPins)) { + const actual = createHash("sha256").update(readFileSync(path)).digest("hex"); + expect( + actual, + `${path} changed: update the held inventory and review scope; a hash is not clinical approval`, + ).toBe(pin); + } + }); + + it("inventories every exported physiological calculation", () => { + const exported = ["renal", "qtc", "lithium"].flatMap((name) => { + const code = readFileSync(`src/lib/calculators/clinical-math/${name}.ts`, "utf8"); + return [...code.matchAll(/export function (\w+)\(/g)].map((match) => match[1]); + }); + expect(governance.algorithms.flatMap((entry) => entry.exports).sort()).toEqual(exported.sort()); + }); +}); diff --git a/tests/clinical-math-panel-breakpoint.dom.test.tsx b/tests/clinical-math-panel-breakpoint.dom.test.tsx index 1c7790cfa0..a2864cb332 100644 --- a/tests/clinical-math-panel-breakpoint.dom.test.tsx +++ b/tests/clinical-math-panel-breakpoint.dom.test.tsx @@ -1,6 +1,6 @@ /** @vitest-environment jsdom */ -import { act, fireEvent, render, screen } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("next/navigation", () => ({ @@ -52,29 +52,32 @@ describe("bedside calculator panel on the calculators page", () => { it("is not rendered in the sidebar or the mobile column at either breakpoint", () => { const setDesktop = installDesktopQuery(false); render(); - expect(screen.queryByTestId("clinical-math-panel-toggle")).toBeNull(); + const expectPanelAbsent = () => { + expect(screen.queryByTestId("clinical-math-panel")).not.toBeInTheDocument(); + expect(screen.queryByTestId("clinical-math-panel-toggle")).not.toBeInTheDocument(); + expect(screen.queryByTestId("clinical-math-release-hold")).not.toBeInTheDocument(); + expect(screen.queryByTestId("clinical-math-lithium-suggested-dose")).not.toBeInTheDocument(); + }; + expectPanelAbsent(); setDesktop(true); - expect(screen.queryByTestId("clinical-math-panel-toggle")).toBeNull(); + expectPanelAbsent(); + + setDesktop(false); + expectPanelAbsent(); }); }); describe("bedside calculator panel component (unwired, kept for clinician verification)", () => { - it("shows the lithium toxicity alert from 1.2 mmol/L before an age is entered", () => { - render(); - - fireEvent.click(screen.getByTestId("clinical-math-panel-toggle")); - fireEvent.click(screen.getByTestId("clinical-math-tab-lithium")); - const level = screen.getByTestId("clinical-math-lithium-level"); - - fireEvent.change(level, { target: { value: "0.9" } }); - expect(screen.getByTestId("clinical-math-lithium-results")).toHaveTextContent("Enter current 12-hour trough level"); + it("withholds lithium inputs and management advice while dedicated clinical validation is pending", () => { + render(); - fireEvent.change(level, { target: { value: "1.2" } }); - const results = screen.getByTestId("clinical-math-lithium-results"); - expect(results).toHaveTextContent("Level status: toxic"); - expect(results).toHaveTextContent("TOXICITY ALERT"); - expect(results).toHaveTextContent("Enter patient age to see the target range."); - expect(results).not.toHaveTextContent("target 0"); + expect(screen.getByTestId("clinical-math-release-hold")).toHaveTextContent("dedicated clinical validation"); + expect(screen.getByTestId("clinical-math-release-hold")).toHaveTextContent("approved local reference"); + expect(screen.queryByRole("spinbutton")).not.toBeInTheDocument(); + expect(screen.queryByRole("tablist")).not.toBeInTheDocument(); + expect(screen.queryByTestId("clinical-math-lithium-level")).not.toBeInTheDocument(); + expect(screen.queryByTestId("clinical-math-lithium-results")).not.toBeInTheDocument(); + expect(screen.queryByText(/TOXICITY ALERT|Suggested dose:/)).not.toBeInTheDocument(); }); }); diff --git a/tests/cme-evidence-api.test.ts b/tests/cme-evidence-api.test.ts index 2f71f53f62..9cce3b069d 100644 --- a/tests/cme-evidence-api.test.ts +++ b/tests/cme-evidence-api.test.ts @@ -339,6 +339,24 @@ describe("removing a CME evidence file", () => { removal_reason: "Contains a patient name", }; + it("fails closed before storage deletion when the required durable-removal RPC is absent", async () => { + results.push({ data: entry, error: null }, { data: evidence, error: null }); + mocks.rpc.mockResolvedValueOnce({ data: null, error: { code: "PGRST202", message: "Missing durable RPC" } }); + expect((await remove(removeRequest({ reason: "Wrong file uploaded" }), downloadContext)).status).toBe(503); + expect(mocks.rpc).toHaveBeenCalledWith("cme_remove_evidence_v2", expect.any(Object)); + expect(mocks.remove).not.toHaveBeenCalled(); + }); + + it("retries a committed removal after its response was lost", async () => { + results.push({ data: entry, error: null }, { data: evidence, error: null }); + mocks.rpc.mockResolvedValueOnce({ data: null, error: { message: "Synthetic lost response after commit" } }); + expect((await remove(removeRequest({ reason: "Wrong file uploaded" }), downloadContext)).status).toBe(503); + results.push({ data: entry, error: null }, { data: removedRow, error: null }); + mocks.rpc.mockResolvedValueOnce({ data: removedRow, error: null }); + expect((await remove(removeRequest({ reason: "Wrong file uploaded" }), downloadContext)).status).toBe(200); + expect(mocks.rpc).toHaveBeenCalledTimes(2); + }); + it("records the reason, deletes the stored file, and returns the tombstone", async () => { results.push({ data: entry, error: null }, { data: { ...evidence, removed_at: null }, error: null }); mocks.rpc.mockResolvedValue({ data: removedRow, error: null }); @@ -346,7 +364,7 @@ describe("removing a CME evidence file", () => { expect(response.status).toBe(200); const body = await response.json(); expect(body.evidence).toMatchObject({ removalReason: "Contains a patient name", fileName: "Removed file" }); - expect(mocks.rpc).toHaveBeenCalledWith("cme_remove_evidence", { + expect(mocks.rpc).toHaveBeenCalledWith("cme_remove_evidence_v2", { p_owner_id: ownerId, p_evidence_id: evidenceId, p_reason: "Contains a patient name", @@ -372,15 +390,15 @@ describe("removing a CME evidence file", () => { expect((await remove(removeRequest({ reason: "Wrong file uploaded" }), downloadContext)).status).toBe(200); }); - it("queues cleanup when storage does not answer, and still reports success", async () => { - results.push({ data: entry, error: null }, { data: evidence, error: null }, { data: null, error: null }); + it("relies on the atomic cleanup obligation when storage does not answer", async () => { + results.push({ data: entry, error: null }, { data: evidence, error: null }); mocks.rpc.mockResolvedValue({ data: removedRow, error: null }); mocks.remove.mockResolvedValue({ data: null, error: { message: "storage down" } }); expect((await remove(removeRequest({ reason: "Wrong file uploaded" }), downloadContext)).status).toBe(200); - expect(queries.at(-1)?.table).toBe("storage_cleanup_jobs"); + expect(queries.some((query) => query.table === "storage_cleanup_jobs")).toBe(false); }); - it("says not found for a file already removed or owned by someone else", async () => { + it("says not found for a file owned by someone else", async () => { results.push({ data: entry, error: null }, { data: null, error: null }); expect((await remove(removeRequest({ reason: "Wrong file uploaded" }), downloadContext)).status).toBe(404); expect(mocks.rpc).not.toHaveBeenCalled(); diff --git a/tests/diagnostic-minimisation.test.ts b/tests/diagnostic-minimisation.test.ts new file mode 100644 index 0000000000..f8d952a642 --- /dev/null +++ b/tests/diagnostic-minimisation.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as privacy from "@/lib/privacy"; +import { redactLogContext } from "@/lib/logger"; + +const privateText = "Synthetic Person 1990-01-02 private-guideline.pdf"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.resetModules(); + vi.doUnmock("@/lib/supabase/admin"); + vi.doUnmock("../worker/observability"); +}); + +describe("content-free operational diagnostics", () => { + it("keeps only fixed classifications and operational codes, never error prose", () => { + const error = Object.assign(new Error(privateText), { + code: "23505", + status: 503, + details: privateText, + hint: privateText, + }); + const details = privacy.contentFreeErrorDetails(error); + expect(details).toEqual({ errorType: "Error", errorCode: "23505", status: 503 }); + expect(JSON.stringify(details)).not.toContain(privateText); + }); + + it("does not trust arbitrary names, codes, objects or thrown strings", () => { + for (const error of [ + Object.assign(new Error(privateText), { name: privateText, code: privateText, status: 1000 }), + privateText, + { message: privateText, code: { privateText }, status: privateText }, + ]) { + const text = JSON.stringify(privacy.contentFreeErrorDetails(error)); + expect(text).not.toContain(privateText); + expect(text).not.toContain("1990-01-02"); + } + }); + + it("redacts user-controlled filenames and storage paths at the logger boundary", () => { + expect( + redactLogContext({ fileName: privateText, storagePath: "owner/documents/" + privateText, fileCount: 1 }), + ).toEqual({ fileName: "[redacted]", storagePath: "[redacted]", fileCount: 1 }); + }); + + it("never puts a fatal exception's content in console or webhook output", async () => { + const error = Object.assign(new Error(privateText), { code: "23505" }); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: () => { + throw error; + }, + })); + vi.doMock("../worker/observability", () => ({ + captureWorkerException: vi.fn(), + flushWorkerErrorTracking: vi.fn(async () => undefined), + })); + vi.stubEnv("WORKER_FAILURE_WEBHOOK_URL", "https://hooks.invalid/synthetic-audit"); + const post = vi.fn(async () => new Response(null, { status: 200 })); + vi.stubGlobal("fetch", post); + const printed = vi.spyOn(console, "error").mockImplementation(() => undefined); + const exit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + const { runWorkerMain } = await import("../worker/main"); + await runWorkerMain(); + expect(exit).toHaveBeenCalledWith(1); + expect(post).toHaveBeenCalledTimes(1); + const sent = String(post.mock.calls[0]?.[1]?.body); + expect(sent).toContain("CRITICAL"); + expect(sent).not.toContain(privateText); + expect(JSON.stringify(printed.mock.calls)).not.toContain(privateText); + }); + + it("reports the allowlisted code of a wrapped abort cause without its content", async () => { + const cause = Object.assign(new Error(privateText), { code: "23505" }); + const { WorkerAbortError } = await import("../worker/runtime-control"); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: () => { + throw new WorkerAbortError("Ingestion job claim failed in --once mode", 1, { cause }); + }, + })); + vi.doMock("../worker/observability", () => ({ + captureWorkerException: vi.fn(), + flushWorkerErrorTracking: vi.fn(async () => undefined), + })); + vi.stubEnv("WORKER_FAILURE_WEBHOOK_URL", "https://hooks.invalid/synthetic-audit"); + const post = vi.fn(async () => new Response(null, { status: 200 })); + vi.stubGlobal("fetch", post); + vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + const { runWorkerMain } = await import("../worker/main"); + await runWorkerMain(); + const sent = String(post.mock.calls[0]?.[1]?.body); + expect(sent).toContain("errorCode=23505"); + expect(sent).not.toContain(privateText); + }); +}); diff --git a/tests/drift-rootless.test.ts b/tests/drift-rootless.test.ts new file mode 100644 index 0000000000..1aff310817 --- /dev/null +++ b/tests/drift-rootless.test.ts @@ -0,0 +1,399 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { gzipSync } from "node:zlib"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { manifestText, parseSnapshot, replaySchema, writeManifestAtomically } from "../scripts/generate-drift-manifest"; +import { diffManifests, IGNORED_MANIFEST_KEYS } from "../scripts/check-drift-manifest-freshness"; +import { + controlPath, + imageEnvironment, + removeOwnedRootlessRun, + rootlessOptions, + ROOTLESS_SUPERVISOR, + verifyOfflineImage, +} from "../scripts/lib/drift-rootless"; + +const directories: string[] = []; +async function temporary() { + const directory = await mkdtemp(path.join(os.tmpdir(), "drift-rootless-test-")); + directories.push(directory); + return realpath(directory); +} +afterEach(async () => { + for (const directory of directories.splice(0)) await rm(directory, { recursive: true, force: true }); +}); +const digest = (bytes: Buffer) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`; + +async function imageFixture( + options: { corruptLayer?: boolean; wrongDiffId?: boolean; architecture?: string; duplicatePlatform?: boolean } = {}, +) { + const directory = await temporary(); + await mkdir(path.join(directory, "blobs/sha256"), { recursive: true }); + const add = async (bytes: Buffer, mediaType: string) => { + const descriptor = { digest: digest(bytes), size: bytes.length, mediaType }; + await writeFile(path.join(directory, "blobs/sha256", descriptor.digest.slice(7)), bytes); + return descriptor; + }; + const raw = Buffer.from("synthetic local layer bytes; no executable image is supplied"); + const layer = await add(gzipSync(raw), "application/vnd.oci.image.layer.v1.tar+gzip"); + const config = { + os: "linux", + architecture: options.architecture ?? "amd64", + rootfs: { diff_ids: [options.wrongDiffId ? `sha256:${"0".repeat(64)}` : digest(raw)] }, + config: { + Env: [ + "PATH=/bin", + "PGDATA=/var/lib/postgresql/data", + "POSTGRES_HOST=/var/run/postgresql", + "POSTGRES_DB=postgres", + ], + Entrypoint: ["docker-entrypoint.sh"], + Cmd: ["postgres", "-D", "/etc/postgresql"], + WorkingDir: "/", + }, + }; + const configDescriptor = await add(Buffer.from(JSON.stringify(config)), "application/vnd.oci.image.config.v1+json"); + const manifest = await add( + Buffer.from( + JSON.stringify({ + mediaType: "application/vnd.oci.image.manifest.v1+json", + config: configDescriptor, + layers: [layer], + }), + ), + "application/vnd.oci.image.manifest.v1+json", + ); + const child = { ...manifest, platform: { os: "linux", architecture: "amd64" } }; + const index = await add( + Buffer.from( + JSON.stringify({ + mediaType: "application/vnd.oci.image.index.v1+json", + manifests: options.duplicatePlatform ? [child, child] : [child], + }), + ), + "application/vnd.oci.image.index.v1+json", + ); + await writeFile(path.join(directory, "oci-layout"), JSON.stringify({ imageLayoutVersion: "1.0.0" })); + await writeFile( + path.join(directory, "index.json"), + JSON.stringify({ manifests: [{ ...manifest, annotations: { "org.opencontainers.image.ref.name": "verified" } }] }), + ); + if (options.corruptLayer) + await writeFile(path.join(directory, "blobs/sha256", layer.digest.slice(7)), "corrupt bytes"); + return { + directory, + image: `supabase/postgres:test@${index.digest}`, + config, + index, + manifest, + layer, + configDescriptor, + }; +} + +const inputs = { + rolesSql: "-- exact role bootstrap", + scaffoldSql: "-- exact storage scaffold", + schemaSql: "-- complete schema\nselect 'do not truncate';", +}; +function completeSnapshot() { + return { + snapshot_version: 2, + captured_at: "volatile", + migration_history_probe: "no_history_table", + migration_history: [], + extensions: [{}], + tables: Array.from({ length: 31 }, (_, id) => ({ name: `t${id}` })), + views: [{}], + functions: Array.from({ length: 41 }, (_, id) => ({ signature: `f${id}()` })), + indexes: Array.from({ length: 101 }, (_, id) => ({ name: `i${id}` })), + policies: [{}], + constraints: [{}], + triggers: [{}], + storage_buckets: [{}], + }; +} + +describe("explicit offline rootless inputs", () => { + const args = [ + "--oci-layout", + "/private/image", + "--scratch-root", + "/private/scratch", + "--umoci", + "/private/umoci", + "--umoci-sha256", + "a".repeat(64), + "--evidence", + "/private/evidence.json", + ]; + it.each(["--keep", "--port", "--container"])( + "rejects unsupported Docker option %s before inspecting inputs", + (flag) => { + expect(() => rootlessOptions([...args, flag, "5432"], "image", "/repo")).toThrow("not supported"); + }, + ); + it("requires every offline path and an exact executable hash", () => { + expect(() => rootlessOptions([], "image", "/repo")).toThrow("--oci-layout"); + expect(() => + rootlessOptions( + args.map((arg) => (arg === "a".repeat(64) ? "bad" : arg)), + "image", + "/repo", + ), + ).toThrow("SHA-256"); + expect(rootlessOptions(args, "image", "/repo").umociSha256).toBe("a".repeat(64)); + }); + it("verifies actual index, child/config, compressed bytes and ordered uncompressed diff IDs", async () => { + const fixture = await imageFixture(); + const verified = await verifyOfflineImage(fixture.directory, fixture.image); + expect(verified.manifest_digest).toBe(fixture.manifest.digest); + expect(verified.config_digest).toBe(fixture.configDescriptor.digest); + expect(verified.layers[0].digest).toBe(fixture.layer.digest); + expect(verified.layers[0].diff_id).toBe(fixture.config.rootfs.diff_ids[0]); + expect(verified.tag).toBe("verified"); + }); + it.each([{ corruptLayer: true }, { wrongDiffId: true }, { architecture: "arm64" }, { duplicatePlatform: true }])( + "refuses invalid local provenance %j", + async (options) => { + const fixture = await imageFixture(options); + await expect(verifyOfflineImage(fixture.directory, fixture.image)).rejects.toThrow(); + }, + ); + it("rejects the verified tag when another local descriptor uses it for different image bytes", async () => { + const fixture = await imageFixture(); + const annotations = { "org.opencontainers.image.ref.name": "verified" }; + await writeFile( + path.join(fixture.directory, "index.json"), + JSON.stringify({ + manifests: [ + { ...fixture.manifest, annotations }, + { digest: `sha256:${"0".repeat(64)}`, size: 1, mediaType: fixture.manifest.mediaType, annotations }, + ], + }), + ); + await expect(verifyOfflineImage(fixture.directory, fixture.image)).rejects.toThrow("unambiguous tag"); + }); + + it("does not trust the pinned filename when its index bytes were changed", async () => { + const fixture = await imageFixture(); + await writeFile(path.join(fixture.directory, "blobs/sha256", fixture.index.digest.slice(7)), "{}"); + await expect(verifyOfflineImage(fixture.directory, fixture.image)).rejects.toThrow("index digest mismatch"); + }); + it("refuses a missing blob without attempting acquisition", async () => { + const fixture = await imageFixture(); + await rm(path.join(fixture.directory, "blobs/sha256", fixture.configDescriptor.digest.slice(7))); + await expect(verifyOfflineImage(fixture.directory, fixture.image)).rejects.toThrow(); + }); + it.skipIf(process.platform === "win32")( + "refuses symlink control paths without rejecting ordinary image content", + async () => { + const fixture = await imageFixture(); + const alias = path.join(await temporary(), "alias"); + await symlink(fixture.directory, alias); + await expect(controlPath(alias, true)).rejects.toThrow("symlinks"); + await expect(controlPath(fixture.directory, true)).resolves.toBe(fixture.directory); + }, + ); + it("uses verified image settings and rejects changed startup or socket contracts", async () => { + const fixture = await imageFixture(); + expect(imageEnvironment(fixture.config).POSTGRES_HOST).toBe("/var/run/postgresql"); + const changed = structuredClone(fixture.config); + changed.config.Env = changed.config.Env.map((entry) => + entry.startsWith("POSTGRES_HOST=") ? "POSTGRES_HOST=live.example" : entry, + ); + expect(() => imageEnvironment(changed)).toThrow("bootstrap differs"); + changed.config.Env = [...fixture.config.config.Env, "LD_PRELOAD=/host/injected.so"]; + expect(() => imageEnvironment(changed)).toThrow("environment"); + }); +}); + +describe("shared actual schema replay contract", () => { + it("applies exact inputs in order, dynamically selects Storage owner, and captures only after full replay", async () => { + const calls: Array<{ role: string; sql: string; tuples: boolean }> = []; + const snapshot = await replaySchema(async (role, sql, tuples = false) => { + calls.push({ role, sql, tuples }); + if (calls.length === 2) return " synthetic_storage_owner\n"; + if (calls.length === 5) return JSON.stringify(completeSnapshot()); + return ""; + }, inputs); + expect(calls.map(({ role }) => role)).toEqual([ + "postgres", + "postgres", + "synthetic_storage_owner", + "postgres", + "postgres", + ]); + expect(calls[0].sql).toBe(inputs.rolesSql); + expect(calls[2].sql).toBe(inputs.scaffoldSql); + expect(calls[3].sql).toBe(inputs.schemaSql); + expect(calls[4]).toEqual({ role: "postgres", sql: "select public.schema_drift_snapshot()::text;", tuples: true }); + expect(snapshot).not.toHaveProperty("captured_at"); + expect(snapshot.functions).toHaveLength(41); + }); + it("refuses an unavailable storage owner before scaffold or schema writes", async () => { + const sql = vi.fn().mockResolvedValue(""); + await expect(replaySchema(sql, inputs)).rejects.toThrow("owner"); + expect(sql).toHaveBeenCalledTimes(2); + }); + it.each([1, 3, 4, 5])("aborts on SQL stage %i without overwriting the previous manifest", async (failedStage) => { + const output = path.join(await temporary(), "manifest.json"); + await writeFile(output, "previous manifest bytes"); + let stage = 0; + await expect( + (async () => { + const snapshot = await replaySchema(async () => { + stage += 1; + if (stage === failedStage) throw new Error("synthetic SQL failure"); + return stage === 2 ? "synthetic_storage_owner" : ""; + }, inputs); + writeManifestAtomically(output, manifestText("image", "hash", 1, snapshot)); + })(), + ).rejects.toThrow("synthetic SQL failure"); + expect(stage).toBe(failedStage); + expect(await readFile(output, "utf8")).toBe("previous manifest bytes"); + }); + it("rejects tiny, malformed and fabricated successful-history snapshots", () => { + expect(() => parseSnapshot("{}")).toThrow("inventory"); + expect(() => parseSnapshot("not JSON")).toThrow(); + expect(() => parseSnapshot(JSON.stringify({ ...completeSnapshot(), migration_history_probe: "ok" }))).toThrow( + "contract", + ); + }); + it("keeps one manifest format across backends and does not hide new provenance keys from CI", async () => { + const snapshot = completeSnapshot(); + const docker = JSON.parse(manifestText("original@index", "schema", 1, snapshot)); + const rootless = JSON.parse(manifestText("original@index", "schema", 100, snapshot)); + expect(diffManifests(docker, rootless)).toEqual([]); + expect(rootless.generator).toBe("scripts/generate-drift-manifest.ts"); + expect(Object.keys(rootless).sort()).toEqual([ + "generated_at", + "generator", + "postgres_image", + "replay_seconds", + "schema_sha256", + "snapshot", + ]); + expect([...IGNORED_MANIFEST_KEYS].sort()).toEqual(["generated_at", "replay_seconds"]); + expect(diffManifests(docker, { ...rootless, backend: "rootless" })).toHaveLength(1); + const output = path.join(await temporary(), "manifest.json"); + writeManifestAtomically(output, JSON.stringify(rootless)); + expect(JSON.parse(await readFile(output, "utf8"))).toEqual(rootless); + expect(await readdir(path.dirname(output))).toEqual(["manifest.json"]); + }); +}); + +describe("single-namespace guest supervisor", () => { + it.skipIf(process.platform !== "linux").each([false, true])( + "records actual shutdown outcome (pg_ctl fails: %s)", + async (failedCleanup) => { + const directory = await temporary(); + const bin = path.join(directory, "bin"); + const output = path.join(directory, "output"); + await mkdir(bin); + await mkdir(output); + const executable = (name: string, body: string) => + writeFile(path.join(bin, name), `#!/bin/bash\n${body}\n`, { mode: 0o700 }); + await executable( + "docker-entrypoint.sh", + `trap 'exit 0' TERM +echo $$ > "$PGDATA/server.pid" +echo 'PostgreSQL init process complete; ready for start up.' +while true; do /bin/sleep 0.01; done`, + ); + await executable( + "pg_isready", + `count=0 +if [ -f "$PGDATA/checks" ]; then count=$(cat "$PGDATA/checks"); fi +count=$((count+1)) +printf '%s' "$count" > "$PGDATA/checks" +[ "$count" -ne 2 ]`, + ); + await executable("sleep", "exec /bin/sleep 0.001"); + await executable("psql", 'cat > "$PGDATA/received.sql"; printf "actual fixture response\\n"'); + await executable("pg_ctl", failedCleanup ? "exit 7" : 'kill -TERM "$(cat "$PGDATA/server.pid")"; exit 0'); + // Substitute only the guest mount location; run the maintained shell itself + // against harmless executable fixtures, with no bwrap or database started. + const supervisor = ROOTLESS_SUPERVISOR.replaceAll("/tmp/drift-output", output); + const sql = "select 'fixture';"; + const result = spawnSync("/bin/bash", ["-c", supervisor], { + env: { PATH: `${bin}:/usr/bin:/bin`, NODE_ENV: "test", PGDATA: directory, POSTGRES_HOST: directory }, + input: `synthetic-password\n1 tuples ${Buffer.from("postgres").toString("base64")} ${Buffer.from(sql).toString("base64")}\n`, + encoding: "utf8", + timeout: 5_000, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(failedCleanup ? 1 : 0); + expect(Number(await readFile(path.join(directory, "checks"), "utf8"))).toBeGreaterThanOrEqual(7); + expect(await readFile(path.join(directory, "received.sql"), "utf8")).toBe(sql); + expect((await readFile(path.join(output, "1.status"), "utf8")).trim()).toBe("0"); + expect((await readFile(path.join(output, "cleanup-status"), "utf8")).trim().split(" ")[0]).toBe( + failedCleanup ? "7" : "0", + ); + }, + ); +}); + +describe("owned immutable rootfs directory cleanup", () => { + it.skipIf(process.platform !== "linux")( + "removes 0555 owned directories without chmodding or following an outside symlink", + async () => { + const parent = await temporary(); + const run = await mkdtemp(path.join(parent, "drift-rootless-")); + const readonly = path.join(run, "bundle/rootfs/nix/store/immutable/bin"); + await mkdir(readonly, { recursive: true }); + await writeFile(path.join(readonly, "tool"), "owned disposable bytes", { mode: 0o444 }); + const outside = path.join(await temporary(), "outside"); + await mkdir(outside); + const outsideFile = path.join(outside, "retained"); + await writeFile(outsideFile, "must remain untouched", { mode: 0o444 }); + // The native test runner uses a restrictive umask; establish exact fixture modes. + await chmod(outsideFile, 0o444); + await chmod(outside, 0o555); + await symlink(outside, path.join(readonly, "outside-link")); + await chmod(readonly, 0o555); + await chmod(path.dirname(readonly), 0o555); + const marker = JSON.stringify({ run, nonce: "synthetic-owned-run" }); + await writeFile(path.join(run, "owner.json"), marker); + const identity = await lstat(run); + try { + await removeOwnedRootlessRun(run, { ino: identity.ino, dev: identity.dev, uid: identity.uid, marker }); + await expect(lstat(run)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(outsideFile, "utf8")).toBe("must remain untouched"); + expect((await lstat(outside)).mode & 0o777).toBe(0o555); + expect((await lstat(outsideFile)).mode & 0o777).toBe(0o444); + } finally { + await chmod(outside, 0o700); + // Let fixture disposal work even when the implementation under test fails. + await chmod(path.dirname(readonly), 0o700).catch(() => undefined); + await chmod(readonly, 0o700).catch(() => undefined); + } + }, + ); + + it.skipIf(process.platform !== "linux")( + "refuses a mismatched marker before changing any directory modes", + async () => { + const run = await mkdtemp(path.join(await temporary(), "drift-rootless-")); + const readonly = path.join(run, "untouched"); + await mkdir(readonly, { mode: 0o555 }); + await chmod(readonly, 0o555); + await writeFile(path.join(run, "owner.json"), "actual-marker"); + const identity = await lstat(run); + await expect( + removeOwnedRootlessRun(run, { + ino: identity.ino, + dev: identity.dev, + uid: identity.uid, + marker: "wrong-marker", + }), + ).rejects.toThrow("marker changed"); + expect((await lstat(readonly)).mode & 0o777).toBe(0o555); + expect(await readFile(path.join(run, "owner.json"), "utf8")).toBe("actual-marker"); + }, + ); +}); diff --git a/tests/factsheets-summary-context.test.ts b/tests/factsheets-summary-context.test.ts new file mode 100644 index 0000000000..e709846b2f --- /dev/null +++ b/tests/factsheets-summary-context.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { FACTSHEET_DEMO_NOTICE, factsheets } from "@/lib/factsheets-data"; +import { buildStaticSiteContentRecords } from "@/lib/site-content/adapters"; +import { buildStaticSiteContentManifest, siteContentValueHash } from "@/lib/site-content/site-content-manifest"; +import { SITE_CONTENT_REGISTRY_VERSION } from "@/lib/site-content/site-content-registry"; + +describe("Factsheet short-summary context", () => { + it("preserves demonstration-use context and content month without inventing clinician approval", () => { + const records = buildStaticSiteContentRecords().filter((record) => record.domain === "factsheets"); + expect(records).toHaveLength(factsheets.length); + for (const sheet of factsheets) { + const record = records.find((entry) => entry.logicalId === `factsheets:${sheet.slug}`)!; + expect(record.body).toContain(FACTSHEET_DEMO_NOTICE); + expect(record.body).toContain(`Content month: ${sheet.reviewedOn}`); + expect(record.body).not.toContain("Review date:"); + // Local correspondence review and currentness are distinct from clinician approval. + expect(record.validationStatus).toBe("locally_reviewed"); + expect(record.sourceStatus).toBe("current"); + expect(record.sourceLineage).toContainEqual( + expect.objectContaining({ + sourceId: "repository:src/lib/factsheets-data.ts:summary-context", + sourceHash: siteContentValueHash({ notice: FACTSHEET_DEMO_NOTICE, dateMeaning: "content-month" }), + }), + ); + } + const manifest = buildStaticSiteContentManifest(records, { + gitSha: "b0edabf0b73a806535aa3289e5de8023cf39997f", + registryVersion: SITE_CONTENT_REGISTRY_VERSION, + generatedAt: "2026-10-17T00:00:00.000Z", + }); + expect(manifest.records.every((entry) => entry.eligible)).toBe(true); + }); +}); diff --git a/tests/favourites-session-race.dom.test.tsx b/tests/favourites-session-race.dom.test.tsx new file mode 100644 index 0000000000..d0ba03168e --- /dev/null +++ b/tests/favourites-session-race.dom.test.tsx @@ -0,0 +1,320 @@ +/** @vitest-environment jsdom */ +// Mounted immutable identities; every fetch is intercepted. No backend authorization claim. +import { act, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AccountDataProvider, useAccountData } from "@/components/account-data-provider"; + +const state = vi.hoisted(() => ({ + auth: { + status: "authenticated" as string, + authEpoch: 1, + authorizationHeader: { Authorization: "Bearer synthetic-A" } as Record, + session: { user: { id: "synthetic-A" } }, + markSessionExpired: vi.fn(), + }, +})); +vi.mock("@/lib/supabase/client", () => ({ useAuthSession: () => state.auth })); +function Probe() { + const state = useAccountData(); + return ( +
+ {state.ready ? "ready" : "loading"} + {state.isSaved("therapy", "cbt") ? "saved" : "empty"} + +
+ ); +} +const snapshot = { version: 1, favourites: [], sets: [] }; +function tree() { + return ( + + + + ); +} +describe("audit: favourites do not cross account boundaries", () => { + beforeEach(() => { + state.auth = { + ...state.auth, + status: "authenticated", + authEpoch: state.auth.authEpoch + 1, + authorizationHeader: { Authorization: "Bearer synthetic-A" }, + session: { user: { id: "synthetic-A" } }, + }; + state.auth.markSessionExpired.mockClear(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + for (const transition of ["signed_out", "account-B"] as const) { + it(`ignores account A save completion after ${transition}`, async () => { + let resolvePut!: (response: Response) => void; + const put = new Promise((resolve) => { + resolvePut = resolve; + }); + const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "PUT") return put; + if (!init?.method || init.method === "GET") return Promise.resolve(Response.json(snapshot)); + throw new Error("Audit probe refused unexpected fetch method"); + }); + vi.stubGlobal("fetch", fetchMock); + const view = render(tree()); + await waitFor(() => expect(screen.getByTestId("ready").textContent).toBe("ready")); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(fetchMock.mock.calls.some(([, init]) => init?.method === "PUT")).toBe(true)); + state.auth = { + ...state.auth, + authEpoch: state.auth.authEpoch + 1, + status: transition === "signed_out" ? "signed_out" : "authenticated", + authorizationHeader: transition === "signed_out" ? {} : { Authorization: "Bearer synthetic-B" }, + session: { user: { id: transition === "signed_out" ? "" : "synthetic-B" } }, + }; + view.rerender(tree()); + await waitFor(() => expect(screen.getByTestId("saved").textContent).toBe("empty")); + await act(async () => { + resolvePut(Response.json({ version: 1, saved: true })); + }); + expect(screen.getByTestId("saved").textContent).toBe("empty"); + }); + } +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +function switchAccount() { + state.auth = { + ...state.auth, + authEpoch: state.auth.authEpoch + 1, + authorizationHeader: { Authorization: "Bearer synthetic-B" }, + session: { user: { id: "synthetic-B" } }, + }; +} +const itemA = { + contentType: "therapy", + contentKey: "cbt", + createdAt: "2026-08-23T00:00:00.000Z", + setId: null, + sortOrder: 10, + pinnedAt: null, + lastOpenedAt: null, +}; +const setA = { + id: "4f8a3d2e-c1b0-4a9e-8d7c-6b5a4f3e2d1c", + name: "Ward round", + sortOrder: 0, + createdAt: "2026-08-23T00:00:00.000Z", + updatedAt: "2026-08-23T00:00:00.000Z", +}; +const wrapper = ({ children }: { children: ReactNode }) => {children}; + +describe("favourites identity fencing for reads, failures and queues", () => { + beforeEach(() => { + state.auth = { + ...state.auth, + status: "authenticated", + authEpoch: state.auth.authEpoch + 1, + authorizationHeader: { Authorization: "Bearer synthetic-A" }, + session: { user: { id: "synthetic-A" } }, + }; + state.auth.markSessionExpired.mockClear(); + }); + afterEach(() => vi.unstubAllGlobals()); + + it("drops an old GET body even when fetch already resolved before the switch", async () => { + const body = deferred(); + let reads = 0; + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve(++reads === 1 ? { ok: true, status: 200, json: () => body.promise } : Response.json(snapshot)), + ), + ); + const view = renderHook(useAccountData, { wrapper }); + await waitFor(() => expect(reads).toBe(1)); + act(() => { + switchAccount(); + view.rerender(); + }); + await waitFor(() => expect(view.result.current.ready).toBe(true)); + await act(async () => body.resolve({ ...snapshot, favourites: [itemA], sets: [setA] })); + expect(view.result.current.favouriteItems).toEqual([]); + expect(view.result.current.favouriteSets).toEqual([]); + }); + + it("clears accepted A state on transition and ignores an old GET 401", async () => { + const pending = deferred(); + let reads = 0; + vi.stubGlobal( + "fetch", + vi.fn(() => { + reads += 1; + if (reads === 1) return Promise.resolve(Response.json({ ...snapshot, favourites: [itemA], sets: [setA] })); + if (reads === 2) return pending.promise; + return Promise.resolve(Response.json(snapshot)); + }), + ); + const view = renderHook(useAccountData, { wrapper }); + await waitFor(() => expect(view.result.current.isSaved("therapy", "cbt")).toBe(true)); + act(() => view.result.current.reload()); + await waitFor(() => expect(reads).toBe(2)); + act(() => { + switchAccount(); + view.rerender(); + }); + expect(view.result.current.favouriteItems).toEqual([]); + expect(view.result.current.favouriteSets).toEqual([]); + await waitFor(() => expect(view.result.current.ready).toBe(true)); + await act(async () => pending.resolve(Response.json({ error: "Synthetic A expired" }, { status: 401 }))); + expect(state.auth.markSessionExpired).not.toHaveBeenCalled(); + expect(view.result.current.loadError).toBeNull(); + }); + + it("does not clear B favourites when A clear-all succeeds late", async () => { + const pending = deferred(); + vi.stubGlobal( + "fetch", + vi.fn((_url: unknown, init?: RequestInit) => + init?.method === "DELETE" + ? pending.promise + : Promise.resolve(Response.json({ ...snapshot, favourites: [itemA] })), + ), + ); + const view = renderHook(useAccountData, { wrapper }); + await waitFor(() => expect(view.result.current.ready).toBe(true)); + let operation!: Promise; + act(() => { + operation = view.result.current.clearFavourites(); + }); + await act(async () => {}); + act(() => { + switchAccount(); + view.rerender(); + }); + await waitFor(() => expect(view.result.current.ready).toBe(true)); + await act(async () => { + pending.resolve(Response.json({ version: 1, cleared: true })); + await operation; + }); + expect(view.result.current.isSaved("therapy", "cbt")).toBe(true); + }); + + for (const kind of ["membership", "structured", "clear"] as const) { + it(`ignores stale ${kind} 401 and rollback after B has loaded`, async () => { + const pending = deferred(); + vi.stubGlobal( + "fetch", + vi.fn((_url: unknown, init?: RequestInit) => + init?.method + ? pending.promise + : Promise.resolve(Response.json({ ...snapshot, favourites: [itemA], sets: [setA] })), + ), + ); + const view = renderHook(useAccountData, { wrapper }); + await waitFor(() => expect(view.result.current.ready).toBe(true)); + let operation!: Promise; + act(() => { + operation = + kind === "membership" + ? view.result.current.setFavourite("therapy", "cbt", false) + : kind === "structured" + ? view.result.current.deleteFavouriteSet(setA.id) + : view.result.current.clearFavourites(); + }); + await act(async () => {}); + act(() => { + switchAccount(); + view.rerender(); + }); + await waitFor(() => expect(view.result.current.ready).toBe(true)); + const before = view.result.current.favouriteItems; + await act(async () => { + pending.resolve(Response.json({ error: "Synthetic expired A" }, { status: 401 })); + await operation; + }); + expect(state.auth.markSessionExpired).not.toHaveBeenCalled(); + expect(view.result.current.error).toBeNull(); + expect(view.result.current.favouriteItems).toEqual(before); + expect(view.result.current.favouriteSets).toEqual([setA]); + }); + } + + it("does not dispatch old queued membership, structured or clear writes, and B does not wait on A", async () => { + const pending = deferred(); + const writes: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn((_url: unknown, init?: RequestInit) => { + if (!init?.method) return Promise.resolve(Response.json(snapshot)); + writes.push(`${state.auth.session.user.id}:${init.method}`); + return writes.length === 1 ? pending.promise : Promise.resolve(Response.json({ version: 1, saved: true })); + }), + ); + const view = renderHook(useAccountData, { wrapper }); + await waitFor(() => expect(view.result.current.ready).toBe(true)); + let operations!: Promise[]; + act(() => { + const old = view.result.current; + operations = [ + old.setFavourite("therapy", "cbt", true), + old.setFavourite("therapy", "cbt", false), + old.clearFavourites(), + old.createFavouriteSet("Ward round"), + ]; + }); + await waitFor(() => expect(writes).toHaveLength(1)); + act(() => { + switchAccount(); + view.rerender(); + }); + await waitFor(() => expect(view.result.current.ready).toBe(true)); + await act(async () => { + await view.result.current.setFavourite("therapy", "cbt", true); + }); + expect(writes).toEqual(["synthetic-A:PUT", "synthetic-B:PUT"]); + await act(async () => { + pending.resolve(Response.json({ version: 1, saved: true })); + await Promise.all(operations); + }); + expect(writes).toEqual(["synthetic-A:PUT", "synthetic-B:PUT"]); + expect(view.result.current.isSaved("therapy", "cbt")).toBe(true); + expect(view.result.current.favouriteSets).toEqual([]); + }); + + for (const status of [200, 500]) { + it(`does not apply a stale create-set response or rollback (${status})`, async () => { + const pending = deferred(); + vi.stubGlobal( + "fetch", + vi.fn((_url: unknown, init?: RequestInit) => + init?.method ? pending.promise : Promise.resolve(Response.json(snapshot)), + ), + ); + const view = renderHook(useAccountData, { wrapper }); + await waitFor(() => expect(view.result.current.ready).toBe(true)); + let operation!: Promise; + act(() => { + operation = view.result.current.createFavouriteSet("Ward round"); + }); + await act(async () => {}); + act(() => { + switchAccount(); + view.rerender(); + }); + await waitFor(() => expect(view.result.current.ready).toBe(true)); + await act(async () => { + pending.resolve(Response.json({ version: 1, set: setA }, { status })); + await operation; + }); + expect(view.result.current.favouriteSets).toEqual([]); + expect(view.result.current.error).toBeNull(); + }); + } +}); diff --git a/tests/fixtures/site-content/static-manifest-baseline.json b/tests/fixtures/site-content/static-manifest-baseline.json index 5156d89cf8..63fbf4b7c4 100644 --- a/tests/fixtures/site-content/static-manifest-baseline.json +++ b/tests/fixtures/site-content/static-manifest-baseline.json @@ -1,8 +1,8 @@ { "version": "clinical-kb-site-static-manifest-v1", - "gitSha": "16832faffe459985b3e176f683aa4f9c9094c5cd", + "gitSha": "54559adabe5b51396c2d1c0eb395f2cf79b43fbf", "registryVersion": "site-content-registry-v1", - "generatedAt": "2026-10-17T17:23:05.499Z", + "generatedAt": "2026-10-18T05:48:14.293Z", "records": [ { "logicalId": "calculators:auditc", @@ -2725,8 +2725,8 @@ "logicalId": "factsheets:bipolar", "domain": "factsheets", "route": "/factsheets/bipolar", - "contentHash": "a92159da9b9b41939644e2c86ac0166722a3e7cffd6f55f87f69ce9887fb7efd", - "lineageDigest": "2d4b2afae02c62d3880f6c802c6b38484038842eaf444f8dcfb6447fc0a78219", + "contentHash": "10fe0b3c731f8eea62e0d40275704b8cf0332785f43a36f841f5b64485e1f919", + "lineageDigest": "6dcdac238929ae64ebe756764793dd58084bc7e969402c52bb0e5968398ad7f7", "validationStatus": "locally_reviewed", "sourceStatus": "current", "eligible": true, @@ -2736,8 +2736,8 @@ "logicalId": "factsheets:cbt", "domain": "factsheets", "route": "/factsheets/cbt", - "contentHash": "b30d24a8c7081f1b978c7a2c62e77048253402b067b019ccdb0b9c67bd3244a4", - "lineageDigest": "6856e31c0c8725843e38b216f87ac1d4739bd20f32b5500b62f676cb604b61fd", + "contentHash": "6895f5f50c833f1cfb3c801e6bbcd52d69a9d8ee60e314eaa6a89d0e07ed4332", + "lineageDigest": "e3e1e557007c5e72cc6a814a775974e0227eb5d7afbc19bf0327a8d0e0589bcd", "validationStatus": "locally_reviewed", "sourceStatus": "current", "eligible": true, @@ -2747,8 +2747,8 @@ "logicalId": "factsheets:depression", "domain": "factsheets", "route": "/factsheets/depression", - "contentHash": "d40a9b04bda2ef36c57b97deefb93e050ea8f88a25ef245ec72e2871803959f0", - "lineageDigest": "dac713b7f938eb021429cc79e8327161023fac6757322be77a72174c2ac8f74c", + "contentHash": "9e66c7567b708025050a635b4e65725863444eb81663292a722b9d277184f5e3", + "lineageDigest": "01ec9a7bd8cf54713db4a65e9cdf20aa385a8fe6161f52bd0291ce9b72687694", "validationStatus": "locally_reviewed", "sourceStatus": "current", "eligible": true, @@ -2758,8 +2758,8 @@ "logicalId": "factsheets:escitalopram", "domain": "factsheets", "route": "/factsheets/escitalopram", - "contentHash": "7669ae21c45903ee01fe0e9059ebd6113195f4562e4735d94d407f6503ed3d5b", - "lineageDigest": "d8b214dcc3d5a981ebce8576e9b43cded6a308c680b2cba64f2539ae8f442133", + "contentHash": "165f7f11b7baaf7f5c4807e901b2f06f5f4815160f193dca230ee2bb8636ff50", + "lineageDigest": "d061e4d83767d4bb35beaec22deffde9f2d81d0e09ab25efdcdb62077f0701a8", "validationStatus": "locally_reviewed", "sourceStatus": "current", "eligible": true, @@ -2769,8 +2769,8 @@ "logicalId": "factsheets:gad", "domain": "factsheets", "route": "/factsheets/gad", - "contentHash": "74efb9e5959a492e49f16cf27c498a861c27e19d258803b597b2e1a7ecfdeedd", - "lineageDigest": "25db1350b99ae8a773813751dd749714d16cbb1161afa27be1e47bcdfc95cb71", + "contentHash": "89d091766b9c961f5dbca7aee46f78bd8932b499fa21f89a1838d6ce49ea47f3", + "lineageDigest": "7b60809b4dba169a59637f762b2518fa121959c73ee034bd1599172fdb6405d0", "validationStatus": "locally_reviewed", "sourceStatus": "current", "eligible": true, @@ -2780,8 +2780,8 @@ "logicalId": "factsheets:lithium-monitoring", "domain": "factsheets", "route": "/factsheets/lithium-monitoring", - "contentHash": "ed1486067712961fcf401462f53f882270604649d370f5d4060e9480c377fd79", - "lineageDigest": "da0ce7940af767645ee14202b20632be33cc646c442ddd836bcf7d26e1a919af", + "contentHash": "e3691a75f2805cb8d1929698d3a6c67d4826bd6a3c74c0b07bcb76dd20a5f969", + "lineageDigest": "4c0252f50958f1af3475500165dd9254646316dc6b1b8642956a58c3d72afb5d", "validationStatus": "locally_reviewed", "sourceStatus": "current", "eligible": true, @@ -2791,8 +2791,8 @@ "logicalId": "factsheets:sertraline", "domain": "factsheets", "route": "/factsheets/sertraline", - "contentHash": "9d6334907b4036de6b69c65560295642f1ec695dd8cd0c9d87c37871ad3864aa", - "lineageDigest": "955225cffd0d052a251e1b273231efe484dd9c060135fc2c072323737d34c4f9", + "contentHash": "d16c9433b37fd252d8b6b357f65739faf03cd9b1abab683cb0a03241e03941ac", + "lineageDigest": "5a4673b20d5b4ec60fdfdc61a5d0315d26e009e3c263d3d4452bf4ca82d14afc", "validationStatus": "locally_reviewed", "sourceStatus": "current", "eligible": true, @@ -2802,8 +2802,8 @@ "logicalId": "factsheets:ssri", "domain": "factsheets", "route": "/factsheets/ssri", - "contentHash": "364bf348c517e1ff6d946ae3a48f8b6165dcec712610edc525d0aac6ee1cc01d", - "lineageDigest": "7d9f793f51a4a0ce369f74c4191edc5109f59eb83c8b7610c94a6e3262a9fcac", + "contentHash": "c77b329227553d9296f0369258bf5006f4082fc948931560b393176d64b714ed", + "lineageDigest": "0aa342e6f624da2edba359b747d433aed012feca3e3bded14b14ad982308e9fc", "validationStatus": "locally_reviewed", "sourceStatus": "current", "eligible": true, @@ -11896,5 +11896,5 @@ "exclusionReason": null } ], - "staticManifestDigest": "c2eab707d745c5ff7075a1f0b124ea709fabd47a6f4d89797027b55705e10ae1" + "staticManifestDigest": "57218410ef7412d5cae9056a8dee8b09f38bf0f133a311a130ceb8ad37529941" } diff --git a/tests/hosted-migration-role-guard.test.ts b/tests/hosted-migration-role-guard.test.ts index bed49122bc..4b41397877 100644 --- a/tests/hosted-migration-role-guard.test.ts +++ b/tests/hosted-migration-role-guard.test.ts @@ -515,7 +515,7 @@ describe("hosted migration-role guard", { timeout: 60_000 }, () => { const runbook = readFileSync("docs/disaster-recovery-runbook.md", "utf8"); expect(generator).toContain("pg_catalog.pg_get_userbyid(nspowner)"); - expect(generator).toContain("psql(storageSchemaOwner, scaffoldSql)"); + expect(generator).toContain("await sql(storageSchemaOwner, inputs.scaffoldSql)"); expect(runbook).toContain("storage_owner="); expect(runbook).toContain('psql -U "${storage_owner}"'); expect(generator).not.toContain(RESERVED_HOSTED_ROLE); diff --git a/tests/ingestion-recovery.test.ts b/tests/ingestion-recovery.test.ts index 005b20730b..0275041f00 100644 --- a/tests/ingestion-recovery.test.ts +++ b/tests/ingestion-recovery.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildIngestionRecoveryPlan, + applyIngestionRecoveryActions, reconcileIngestionRecoveryPlan, INGESTION_RECOVERY_JOB_STATUSES, isFreshProcessingJob, @@ -11,6 +12,42 @@ import { describe("ingestion queue recovery planning", () => { const now = new Date("2026-06-15T00:00:00.000Z"); + it("keeps a current lease even when the legacy lock timestamp is old", () => { + const plan = buildIngestionRecoveryPlan({ + now, + staleAfterMinutes: 45, + jobs: [ + { + id: "current", + document_id: "doc", + status: "processing", + locked_at: "2026-06-14T20:00:00Z", + lease_expires_at: "2026-06-15T00:10:00Z", + documents: { status: "processing" }, + }, + ], + }); + expect(plan.actions).toEqual([]); + }); + + it("recovers an expired final attempt even with a recent legacy timestamp", () => { + const plan = buildIngestionRecoveryPlan({ + now, + staleAfterMinutes: 45, + jobs: [ + { + id: "expired", + document_id: "doc", + status: "processing", + locked_at: "2026-06-14T23:59:00Z", + lease_expires_at: "2026-06-14T23:59:59Z", + documents: { status: "processing" }, + }, + ], + }); + expect(plan.actions).toEqual([{ action: "retry", jobId: "expired", documentId: "doc", resetDocument: false }]); + }); + it("loads pending jobs alongside failed and processing siblings", () => { expect(INGESTION_RECOVERY_JOB_STATUSES).toEqual(["pending", "processing", "failed"]); }); @@ -37,7 +74,7 @@ describe("ingestion queue recovery planning", () => { }); expect(plan.retryCount).toBe(2); - expect(plan.resetDocumentIds.sort()).toEqual(["doc-a", "doc-b"]); + expect(plan.resetDocumentIds).toEqual([]); }); it("supersedes obsolete failed jobs for already indexed documents", () => { @@ -233,6 +270,8 @@ describe("ingestion recovery plan reconciliation (R20/R21)", () => { expected: { jobStatus: "processing", jobLockedAt: "2026-06-14T22:00:00.000Z", + jobLeaseToken: null, + jobLeaseExpiresAt: null, documentStatus: "processing", documentOwnerId: "owner-1", }, @@ -302,3 +341,44 @@ describe("ingestion recovery plan reconciliation (R20/R21)", () => { expect(applicable.map((entry) => entry.action.action)).toEqual(["supersede", "retry"]); }); }); + +describe("shared operator recovery application", () => { + const expected = { + jobStatus: "failed", + jobLockedAt: null, + jobLeaseToken: "lease", + jobLeaseExpiresAt: null, + documentStatus: "failed", + documentOwnerId: "owner", + }; + const action = { action: "retry" as const, jobId: "job", documentId: "doc", resetDocument: false }; + it("passes the complete observed authority snapshot to SQL and respects ownership loss", async () => { + const calls: unknown[] = []; + const client = { + rpc: async (...args: unknown[]) => { + calls.push(args); + return { data: { ok: false, reason: "active_lease" }, error: null }; + }, + }; + expect(await applyIngestionRecoveryActions(client, [{ action, expected }])).toEqual([ + { action, applied: false, reason: "active_lease" }, + ]); + expect(calls).toEqual([["recover_ingestion_job", { p_job_id: "job", p_action: "retry", p_expected: expected }]]); + }); + it("stops before any follow-up operation when required recovery SQL is unavailable", async () => { + let calls = 0; + const client = { + rpc: async () => { + calls += 1; + return { data: null, error: { message: "Missing recovery RPC" } }; + }, + }; + await expect( + applyIngestionRecoveryActions(client, [ + { action, expected }, + { action, expected }, + ]), + ).rejects.toThrow("Missing recovery RPC"); + expect(calls).toBe(1); + }); +}); diff --git a/tests/instrumentation.test.ts b/tests/instrumentation.test.ts index 21089983b9..4b7e8d3994 100644 --- a/tests/instrumentation.test.ts +++ b/tests/instrumentation.test.ts @@ -1,4 +1,58 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRequire } from "node:module"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Exercise the installed Next webpack parser/DefinePlugin, rather than only +// calling register() with Vitest's runtime import mocks. A negative early return +// can be safe at execution time while webpack still resolves later Node imports +// into its Edge graph. Externalizing dependencies records that resolution without +// executing boot work, starting timers or reaching any provider. +async function compiledRuntimeImports(runtime: "nodejs" | "edge") { + type Stats = { toJson: (options: { all: boolean; errors: boolean }) => { errors?: unknown[] } }; + type Compiler = { close: (callback: (error?: Error | null) => void) => void }; + type ExternalCallback = (error?: Error | null, external?: string) => void; + const { webpack } = createRequire(import.meta.url)("next/dist/compiled/webpack/webpack") as { + webpack: ((config: unknown, callback: (error: Error | null, stats?: Stats) => void) => Compiler) & { + DefinePlugin: new (definitions: Record) => unknown; + }; + }; + const directory = await mkdtemp(join(tmpdir(), "instrumentation-runtime-")); + const imports = new Set(); + try { + await new Promise((resolve, reject) => { + const compiler = webpack( + { + mode: "none", + target: "webworker", + entry: join(process.cwd(), "src/instrumentation.ts"), + output: { path: directory, filename: "instrumentation.js" }, + plugins: [new webpack.DefinePlugin({ "process.env.NEXT_RUNTIME": JSON.stringify(runtime) })], + externals: [ + ({ request }: { request?: string }, callback: ExternalCallback) => { + if (request?.startsWith("@/") || request?.startsWith("./sentry.")) { + imports.add(request); + callback(null, `commonjs ${request}`); + } else callback(); + }, + ], + }, + (error, stats) => { + compiler.close((closeError) => { + if (error || closeError) return reject(error ?? closeError); + const errors = stats?.toJson({ all: false, errors: true }).errors ?? []; + if (errors.length) return reject(new Error(JSON.stringify(errors))); + resolve(); + }); + }, + ); + }); + return imports; + } finally { + await rm(directory, { recursive: true, force: true }); + } +} // The boot guard (src/instrumentation.ts) must refuse to start a production server // that is misconfigured (missing/mismatched Supabase or required OpenAI config), @@ -65,6 +119,29 @@ afterEach(() => { }); describe("instrumentation boot guard", () => { + it("excludes Node-only startup dependencies from the actual Edge webpack graph", async () => { + const imports = await compiledRuntimeImports("edge"); + expect(imports).toContain("./sentry.edge.config"); + for (const nodeOnly of [ + "./sentry.server.config", + "@/lib/env", + "@/lib/rag/rag-retrieval-variants", + "@/lib/site-content/warm-catalogue-caches", + "@/app/api/setup-status/route", + "@/lib/alerts/timed-sender", + "@/lib/supabase/admin", + ]) + expect(imports, `${nodeOnly} must not resolve in the Edge compilation`).not.toContain(nodeOnly); + }); + + it("retains Node boot dependencies in the actual Node webpack graph", async () => { + const imports = await compiledRuntimeImports("nodejs"); + expect(imports).toContain("./sentry.server.config"); + expect(imports).toContain("@/lib/alerts/timed-sender"); + expect(imports).toContain("@/lib/supabase/admin"); + expect(imports).not.toContain("./sentry.edge.config"); + }); + it("refuses to start a production server with missing Supabase config", async () => { const register = await loadRegister({ ...PRODUCTION_NODE }); await expect(register()).rejects.toThrow(/Missing server environment variables/); diff --git a/tests/open-shifts-zone.test.ts b/tests/open-shifts-zone.test.ts new file mode 100644 index 0000000000..055299669e --- /dev/null +++ b/tests/open-shifts-zone.test.ts @@ -0,0 +1,116 @@ +// Audit-only date-boundary probe; all times synthetic, no network. +import { beforeEach, expect, it, vi } from "vitest"; +const work = vi.hoisted(() => ({ zone: "Australia/Sydney" })); +vi.mock("@/lib/work-time/current-zone", () => ({ currentWorkTimeZone: () => work.zone })); +import { hoursMeter } from "@/lib/open-shifts/mine"; +import { groupPosted } from "@/lib/open-shifts/posted"; +import { changeBody, changeRisks, draftFor } from "@/components/teaching/organise-model"; +import { perthWallToIso } from "@/lib/roster/shifts/perth-time"; +import type { SessionSummary } from "@/lib/teaching/model"; +beforeEach(() => { + work.zone = "Australia/Sydney"; +}); + +it("counts the full shift from local midnight in the selected Sydney work zone", () => { + // 17 October 00:00-08:00 AEDT. The published meter covers 17-30 October, inclusive. + const shift = { + id: "synthetic-sydney", + startsAt: "2026-10-16T13:00:00Z", + endsAt: "2026-10-16T21:00:00Z", + kind: "day" as const, + }; + const meter = hoursMeter([shift], [], new Date("2026-10-17T01:00:00Z"), false); + expect(meter.from).toBe("2026-10-17"); + expect(meter.rostered).toBe(8); +}); + +it.each([ + ["Australia/Perth", "2026-10-17", 8], + ["Australia/Sydney", "2026-07-17", 8], + ["Australia/Sydney", "2026-10-04", 7], + ["Australia/Sydney", "2026-04-05", 9], + ["Australia/Adelaide", "2026-10-17", 8], + ["Australia/Darwin", "2026-10-17", 8], +])("counts actual elapsed hours from local midnight in %s on %s", (zone, date, hours) => { + work.zone = zone; + const startsAt = perthWallToIso(date, "00:00")!; + const endsAt = perthWallToIso(date, "08:00")!; + const meter = hoursMeter([{ id: "shift", startsAt, endsAt, kind: "day" }], [], new Date(startsAt), false); + expect(meter.from).toBe(date); + expect(meter.rostered).toBe(hours); +}); + +it.each(["Australia/Perth", "Australia/Sydney", "Australia/Adelaide"])( + "uses seven calendar days rather than fixed elapsed days in %s across DST", + (zone) => { + work.zone = zone; + const now = new Date(perthWallToIso("2026-10-01", "12:00")!); + const row = (date: string, time: string) => ({ + status: "approved" as const, + startsAt: perthWallToIso(date, time)!, + endsAt: perthWallToIso("2026-10-08", "08:00")!, + }); + const before = row("2026-10-07", "23:59"); + expect(groupPosted([before, row("2026-10-08", "00:00")], now).filledThisWeek).toEqual([before]); + }, +); + +it("does not post a Teaching move into a skipped Sydney clock time", () => { + const session = { + occurrenceId: "session", + serviceId: "service", + title: "Teaching", + startsAt: "2026-10-03T01:00:00Z", + endsAt: "2026-10-03T02:00:00Z", + venue: "Room", + status: "scheduled", + source: "teaching", + hasJoinLink: false, + } as SessionSummary; + const draft = { ...draftFor(session), date: "2026-10-04", startTime: "02:30" }; + expect(changeRisks(session, draft, [])).toEqual([ + expect.objectContaining({ rule: "time", text: expect.stringContaining("valid date and time") }), + ]); + expect(changeBody(session, draft).startsAt).toBe(session.startsAt); +}); + +it("keeps the original instant on either side of a repeated Sydney clock time for a room edit", () => { + const session = { + occurrenceId: "session", + serviceId: "service", + title: "Teaching", + startsAt: "2026-04-04T15:30:15Z", + endsAt: "2026-04-04T16:30:15Z", + venue: "Room A", + status: "scheduled", + source: "teaching", + hasJoinLink: false, + } as SessionSummary; + const body = changeBody(session, { ...draftFor(session), venue: "Room B" }); + expect(body.startsAt).toBe(session.startsAt); + expect(body.endsAt).toBe(session.endsAt); +}); +it("excludes an approved shift starting after the seven local days end", () => { + // Window advertised as next 7 days starts 17 Oct and ends at 24 Oct local midnight. + const shift = { status: "approved" as const, startsAt: "2026-10-23T14:00:00Z", endsAt: "2026-10-23T22:00:00Z" }; + // Starts 24 Oct 01:00 AEDT; fixed +08 boundary incorrectly admits it until 03:00 AEDT. + expect(groupPosted([shift], new Date("2026-10-17T01:00:00Z")).filledThisWeek).toEqual([]); +}); +it("preserves the selected Sydney clock time when moving only a Teaching session room", () => { + const session = { + occurrenceId: "synthetic-session", + serviceId: "synthetic-service", + title: "Synthetic teaching", + startsAt: "2026-10-17T01:00:00Z", + endsAt: "2026-10-17T02:00:00Z", + venue: "Synthetic room A", + status: "scheduled", + source: "teaching", + hasJoinLink: false, + isPresenter: false, + } as SessionSummary; + const draft = { ...draftFor(session), venue: "Synthetic room B" }; + expect(draft.startTime).toBe("12:00"); + // Changing just the room must not move noon Sydney to 15:00 Sydney. + expect(changeBody(session, draft).startsAt).toBe(session.startsAt); +}); diff --git a/tests/operational-alerts.test.ts b/tests/operational-alerts.test.ts index 364ba5da14..03fd787a2d 100644 --- a/tests/operational-alerts.test.ts +++ b/tests/operational-alerts.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; import { evaluateOperationalAlerts, summarizeOperationalAlerts } from "../scripts/lib/operational-alerts.mjs"; function snapshot(overrides = {}) { @@ -15,6 +16,23 @@ function snapshot(overrides = {}) { } describe("evaluateOperationalAlerts", () => { + it("gives every configured alert a runbook with diagnosis and recovery actions", () => { + const source = readFileSync("scripts/lib/operational-alerts.mjs", "utf8"); + const runbook = readFileSync("docs/observability-slos.md", "utf8"); + const codes = [...source.matchAll(/code: "(OPS_[A-Z_]+)"/g)].map((match) => match[1]); + expect(codes.length).toBeGreaterThan(0); + for (const code of codes) { + const section = runbook.split(`### \`${code}\``)[1]?.split(/\n###? /)[0]; + expect(section, `Missing runbook for ${code}`).toBeDefined(); + for (const action of ["Diagnose", "Acknowledge", "Recover", "Escalate"]) { + expect(section, `${code} needs ${action}`).toContain(`**${action}:**`); + } + } + expect(runbook).not.toContain("Only two of the"); + expect(runbook).toContain("latencyBudgetBreachRate"); + expect(runbook).toContain("at least 20"); + }); + it("warns only above the hybrid-RPC 0.5% boundary", () => { expect(evaluateOperationalAlerts(snapshot({ hybridRpcErrorRate: 0.005 }))).toEqual([]); expect(evaluateOperationalAlerts(snapshot({ hybridRpcErrorRate: 0.0051 }))).toMatchObject([ diff --git a/tests/rag-answer-fallback.test.ts b/tests/rag-answer-fallback.test.ts index 07b40804db..5e4d7839a8 100644 --- a/tests/rag-answer-fallback.test.ts +++ b/tests/rag-answer-fallback.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mockRagScopeRevision } from "./rag-cache-test-revision"; import clozapineThresholdChunks from "./fixtures/clozapine-threshold-source-chunks.json"; import lithiumLiveExcerpts from "./fixtures/lithium-monitoring-live-excerpts.json"; import { citationFromResult } from "../src/lib/citations"; @@ -114,6 +115,33 @@ class EmptyQuery implements PromiseLike<{ data: unknown[]; error: null }> { } } +type CacheDocumentFixture = { id: string; owner_id: string | null; metadata: unknown }; + +/** Revision RPC fixture filters the same owner/public/document population as the SQL. */ +function withScopeRevision(client: T, documents: readonly CacheDocumentFixture[]) { + const original = client as T & { rpc: (name: string, params?: Record) => unknown }; + return { + ...client, + rpc(name: string, params: Record) { + if (name !== "rag_cache_scope_revision_v1") return original.rpc(name, params); + const ids = params.p_document_ids as string[] | null; + const scoped = documents.filter( + (row) => + ((params.p_owner_id !== null && row.owner_id === params.p_owner_id) || + (row.owner_id === null && params.p_include_public === true)) && + (!ids?.length || ids.includes(row.id)), + ); + const response = { + abortSignal: () => response, + then(yes?: (value: { data: string; error: null }) => unknown, no?: (error: unknown) => unknown) { + return Promise.resolve({ data: mockRagScopeRevision(scoped), error: null }).then(yes, no); + }, + }; + return response; + }, + }; +} + type GeneratedAnswerPayload = { answer: string; grounded: boolean; @@ -353,28 +381,73 @@ async function answerFromTextSources( }; return query; }; + const cacheDocuments = [ + ...new Map( + governedSources.map((row) => [ + row.document_id, + { + id: row.document_id, + owner_id: null, + metadata: row.source_metadata, + }, + ]), + ).values(), + ]; + let sharedRows: Array> = []; const cacheQuery = (table: string) => { + const filters = new Map(); + let operation = "select"; + let inserted: Record | undefined; + const matches = (row: Record) => + [...filters].every(([key, value]) => + key === "expires_at" + ? String(row[key]) > String(value) + : Array.isArray(value) + ? value.includes(row[key]) + : row[key] === value, + ); + const result = () => { + if (table === "documents") return { data: cacheDocuments.filter(matches), error: null }; + if (operation === "delete") sharedRows = sharedRows.filter((row) => !matches(row)); + if (operation === "insert") sharedRows.push(inserted!); + return { data: [], error: null }; + }; const builder = { select: () => builder, - eq: () => builder, - is: () => builder, + eq: (key: string, value: unknown) => { + filters.set(key, value); + return builder; + }, + is: (key: string, value: unknown) => { + filters.set(key, value); + return builder; + }, or: () => builder, - in: () => builder, - gt: () => builder, + in: (key: string, value: unknown) => { + filters.set(key, value); + return builder; + }, + gt: (key: string, value: unknown) => { + filters.set(key, value); + return builder; + }, order: () => builder, limit: () => builder, abortSignal: () => builder, - delete: () => builder, - insert: () => builder, - maybeSingle: async () => ({ data: null, error: null }), - then: (resolve: (value: { data: unknown[]; error: null }) => unknown) => - Promise.resolve({ - data: - table === "documents" - ? [{ id: "agitation-doc", updated_at: "2026-08-30T00:00:00.000Z", metadata: {} }] - : [], - error: null, - }).then(resolve), + delete: () => { + operation = "delete"; + return builder; + }, + insert: (row: Record) => { + operation = "insert"; + inserted = structuredClone(row); + return builder; + }, + maybeSingle: async () => { + const row = sharedRows.find(matches); + return { data: row ? { payload: structuredClone(row.payload) } : null, error: null }; + }, + then: (resolve: (value: { data: unknown[]; error: null }) => unknown) => Promise.resolve(result()).then(resolve), }; return builder; }; @@ -434,25 +507,29 @@ async function answerFromTextSources( return query; }; vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - rpc, - from: vi.fn((table: string) => - table === "document_chunks" - ? options.governed || options.reviewedPolicyFixture - ? admissionQuery() - : adjacentQuery() - : table === "rag_queries" && options.captureLoggedRow - ? { - insert: vi.fn(async (row) => { - options.captureLoggedRow?.(row); - return { error: null }; - }), - } - : options.repeatRequest && ["documents", "rag_response_cache"].includes(table) - ? cacheQuery(table) - : new EmptyQuery(), + createAdminClient: () => + withScopeRevision( + { + rpc, + from: vi.fn((table: string) => + table === "document_chunks" + ? options.governed || options.reviewedPolicyFixture + ? admissionQuery() + : adjacentQuery() + : table === "rag_queries" && options.captureLoggedRow + ? { + insert: vi.fn(async (row) => { + options.captureLoggedRow?.(row); + return { error: null }; + }), + } + : options.repeatRequest && ["documents", "rag_response_cache"].includes(table) + ? cacheQuery(table) + : new EmptyQuery(), + ), + }, + cacheDocuments, ), - }), })); let generatedAnswerAttemptIndex = 0; const generateStructuredTextResult = vi.fn(async (input: string, _schema: unknown, providerOptions = {}) => { @@ -4906,7 +4983,7 @@ describe("RAG structured-output fallback", () => { } }); - it("does not propagate an originating request's abort to a coalesced concurrent caller", async () => { + it("keeps an already-aborted request separate while live callers coalesce onto one fresh origination", async () => { vi.stubEnv("OPENAI_API_KEY", "test-key"); vi.stubEnv("RAG_SEARCH_CACHE_TTL_MS", "0"); vi.stubEnv("RAG_ANSWER_CACHE_TTL_MS", "300000"); @@ -4936,7 +5013,11 @@ describe("RAG structured-output fallback", () => { })); vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ rpc, from: vi.fn(() => new EmptyQuery()) }), + createAdminClient: () => + withScopeRevision( + { rpc, from: vi.fn(() => new EmptyQuery()) }, + sources.map((row) => ({ id: row.document_id, owner_id: null, metadata: row.source_metadata })), + ), })); vi.doMock("@/lib/openai", () => ({ embedTextWithTelemetry: vi.fn(async () => ({ embedding: [0.1, 0.2, 0.3], cacheHit: false })), @@ -4945,7 +5026,7 @@ describe("RAG structured-output fallback", () => { const { answerQuestionWithScope } = await import("../src/lib/rag/rag"); - // Originating request aborts before it can produce an answer; its shared in-flight promise rejects. + // This request is already aborted and never enters the in-flight map. const controller = new AbortController(); controller.abort(); const first = answerQuestionWithScope({ @@ -4954,7 +5035,7 @@ describe("RAG structured-output fallback", () => { logQuery: false, signal: controller.signal, }); - // Two identical waiters coalesce onto the (now-doomed) in-flight promise. + // The two live callers must share one independently admitted origination. const second = answerQuestionWithScope({ query: "Summarize inpatient approach", ownerId: "owner-1", @@ -4968,11 +5049,12 @@ describe("RAG structured-output fallback", () => { // The originator's failure stays with the originator... await expect(first).rejects.toBeTruthy(); - // ...and the coalesced caller still gets a real, independently generated answer rather than a 500. + // ...and the live callers get one independently generated answer rather than a 500. const [secondAnswer, thirdAnswer] = await Promise.all([second, third]); expect(secondAnswer.openAIRequestIds).toEqual(["req_independent"]); expect(thirdAnswer.openAIRequestIds).toEqual(["req_independent"]); expect(secondAnswer.routingReason ?? "").not.toContain("answer_inflight_coalesced"); + expect(thirdAnswer.routingReason).toContain("answer_inflight_coalesced"); // It ran its OWN pipeline (search + generation once) instead of cloning the failed one. expect(generateStructuredTextResult).toHaveBeenCalledTimes(1); expect(rpc.mock.calls.filter(([name]) => name === "match_document_chunks_text_v2")).toHaveLength(1); @@ -5020,7 +5102,11 @@ describe("RAG structured-output fallback", () => { }); vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ rpc, from: vi.fn(() => new EmptyQuery()) }), + createAdminClient: () => + withScopeRevision( + { rpc, from: vi.fn(() => new EmptyQuery()) }, + sources.map((row) => ({ id: row.document_id, owner_id: null, metadata: row.source_metadata })), + ), })); vi.doMock("@/lib/openai", () => ({ embedTextWithTelemetry: vi.fn(async () => ({ embedding: [0.1, 0.2, 0.3], cacheHit: false })), @@ -5036,12 +5122,20 @@ describe("RAG structured-output fallback", () => { await generationStarted; const controller = new AbortController(); + let markJoined!: () => void; + const joined = new Promise((resolve) => { + markJoined = resolve; + }); const second = answerQuestionWithScope({ query: "Summarize inpatient approach", ownerId: "owner-waiter-cancel", logQuery: false, signal: controller.signal, + onProgress: (event) => { + if (event.reason === "answer_inflight_coalesced") markJoined(); + }, }); + await joined; const reason = new DOMException("waiter left", "AbortError"); controller.abort(reason); await expect(second).rejects.toBe(reason); diff --git a/tests/rag-cache-governance-revocation.test.ts b/tests/rag-cache-governance-revocation.test.ts new file mode 100644 index 0000000000..a968663723 --- /dev/null +++ b/tests/rag-cache-governance-revocation.test.ts @@ -0,0 +1,505 @@ +import { createHash } from "node:crypto"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { SearchTelemetry } from "../src/lib/rag/rag-contracts"; +import type { RagAnswer, SearchResult } from "../src/lib/types"; + +const ownerId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const documentId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; +const args = { query: "monitoring pathway", documentId, ownerId, accessScope: { ownerId, includePublic: true } }; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function source(): SearchResult { + return { + id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + document_id: documentId, + title: "Synthetic guideline", + file_name: "synthetic.txt", + page_number: 1, + chunk_index: 0, + section_heading: "Monitoring", + content: "Use the current monitoring pathway.", + image_ids: [], + images: [], + similarity: 0.9, + source_metadata: { + source_title: "Synthetic guideline", + publisher: "WA Health", + publisher_code: "WA_HEALTH", + jurisdiction: "Australia/WA", + version: null, + publication_date: null, + review_date: null, + uploaded_at: null, + indexed_at: "2026-10-15T00:00:00.000Z", + uploaded_by: null, + document_status: "current", + clinical_validation_status: "approved", + extraction_quality: "good", + }, + }; +} + +function answer(): RagAnswer { + const evidence = source(); + return { + answer: evidence.content, + grounded: true, + confidence: "high", + sources: [evidence], + citations: [ + { + chunk_id: evidence.id, + document_id: documentId, + title: evidence.title, + file_name: evidence.file_name, + page_number: 1, + chunk_index: 0, + }, + ], + }; +} + +const telemetry = { + search_cache_hit: false, + query_class: "document_lookup", + text_fast_path_latency_ms: 0, + embedding_skipped: true, + embedding_latency_ms: 0, + embedding_cache_hit: false, + supabase_rpc_latency_ms: 0, + rerank_latency_ms: 0, +} as SearchTelemetry; + +/** Synthetic DB with actual matching cache-row filters, never a provider connection. */ +function installStorage() { + const document = { + id: documentId, + owner_id: ownerId as string | null, + status: "indexed", + updated_at: "2026-10-16T00:00:00.000Z", + metadata: { + indexed_at: "2026-10-15T00:00:00.000Z", + index_generation_id: "generation-1", + document_status: "current", + clinical_validation_status: "approved", + }, + }; + const state = { + documents: [document], + rows: [] as Array>, + failDelete: false, + failRevision: false, + malformedRevision: false, + failedDeletes: 0, + inserts: 0, + sharedReads: 0, + pendingInserts: 0, + emptyRetrieval: false, + sharedReadGate: undefined as Promise | undefined, + sharedInsertGate: undefined as Promise | undefined, + revisionReadGate: undefined as Promise | undefined, + }; + function scopeDocuments(params: Record) { + const ids = params.p_document_ids as string[] | null; + return state.documents.filter( + (row) => + ((row.owner_id === params.p_owner_id && params.p_owner_id !== null) || + (row.owner_id === null && params.p_include_public === true)) && + (!ids?.length || ids.includes(row.id)), + ); + } + const rpc = vi.fn(async (name: string, params: Record) => { + if (name === "record_source_review" || name === "record_source_review_v2") { + document.metadata.document_status = "outdated"; + + document.updated_at = "2026-10-17T00:00:00.000Z"; + return { data: { id: "review-1" }, error: null }; + } + if (name !== "rag_cache_scope_revision_v1") { + if (state.emptyRetrieval) return { data: [], error: null }; + throw new Error(`Unexpected offline RPC: ${name}`); + } + const rows = scopeDocuments(params).sort((left, right) => left.id.localeCompare(right.id)); + const revision = `rag-cache-scope-v1:${createHash("md5").update(JSON.stringify(rows)).digest("hex")}`; + await state.revisionReadGate; + return state.failRevision + ? { data: null, error: { code: "PGRST202", message: "RPC unavailable" } } + : { data: state.malformedRevision ? "legacy-unversioned-stamp" : revision, error: null }; + }); + const from = vi.fn((table: string) => { + const filters: Record = {}; + let operation = "select"; + let inserted: Record | undefined; + let maxRows = 1; + const builder: Record = {}; + for (const name of ["select", "or", "order", "abortSignal"]) builder[name] = () => builder; + for (const name of ["eq", "is", "in", "gt"]) + builder[name] = (key: string, value: unknown) => { + filters[key] = value; + return builder; + }; + builder.limit = (limit: number) => { + maxRows = limit; + return builder; + }; + builder.delete = () => { + operation = "delete"; + return builder; + }; + builder.insert = (row: Record) => { + operation = "insert"; + inserted = structuredClone(row); + return builder; + }; + function matches(row: Record) { + return Object.entries(filters).every(([key, value]) => + key === "expires_at" + ? String(row[key]) > String(value) + : Array.isArray(value) + ? value.includes(row[key]) + : row[key] === value, + ); + } + async function result(single: boolean) { + if (table === "documents") { + const docs = state.documents + .filter(matches) + .sort((left, right) => right.updated_at.localeCompare(left.updated_at)); + return { data: structuredClone(single ? (docs[0] ?? null) : docs.slice(0, maxRows)), error: null }; + } + if (table !== "rag_response_cache") { + if (state.emptyRetrieval) return { data: single ? null : [], error: null }; + throw new Error(`Unexpected offline table: ${table}`); + } + if (operation === "delete") { + if (state.failDelete) { + state.failedDeletes++; + return { data: null, error: { code: "57014", message: "synthetic delete timeout" } }; + } + state.rows = state.rows.filter((row) => !matches(row)); + } else if (operation === "insert") { + state.pendingInserts++; + await state.sharedInsertGate; + state.rows.push(inserted!); + state.inserts++; + } else { + const row = state.rows.find(matches); + const data = row ? { payload: structuredClone(row.payload) } : null; + state.sharedReads++; + await state.sharedReadGate; + return { data, error: null }; + } + return { data: null, error: null }; + } + builder.maybeSingle = () => result(true); + builder.then = (fulfilled?: (result: unknown) => unknown, rejected?: (reason: unknown) => unknown) => + result(false).then(fulfilled, rejected); + return builder; + }); + vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => ({ from, rpc }) })); + return { document, state, rpc, from }; +} + +afterEach(() => { + vi.doUnmock("@/lib/env"); + vi.doUnmock("@/lib/supabase/admin"); + vi.doUnmock("@/lib/supabase/auth"); + vi.doUnmock("@/lib/api-rate-limit"); + vi.resetModules(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +async function setup() { + vi.resetModules(); + vi.stubEnv("NEXT_PUBLIC_DEMO_MODE", "false"); + for (const key of ["RAG_ANSWER_CACHE_TTL_MS", "RAG_SEARCH_CACHE_TTL_MS"]) vi.stubEnv(key, "60000"); + for (const key of ["RAG_ANSWER_CACHE_SIZE", "RAG_SEARCH_CACHE_SIZE"]) vi.stubEnv(key, "100"); + vi.doMock("@/lib/env", async (importOriginal) => ({ + ...(await importOriginal()), + isDemoMode: () => false, + })); + const storage = installStorage(); + const cache = await import("../src/lib/rag/rag-cache"); + return { ...storage, cache }; +} + +async function seedBoth( + cache: Awaited>["cache"], + state: Awaited>["state"], +) { + await cache.setCachedAnswer(args, answer()); + await cache.setCachedSearch(args, [source()], telemetry); + await vi.waitFor(() => expect(state.rows).toHaveLength(2)); +} + +describe("durable source-governance cache fence (F13)", () => { + it.each(["rejected", "decommissioned"])( + "rejects retained local and shared answer/search entries after %s review", + async (decision) => { + const { cache, state, document } = await setup(); + await seedBoth(cache, state); + const start = await cache.cacheIndexingVersion(args, { forceRefresh: true }); + expect(await cache.getCachedAnswer(args, Date.now())).not.toBeNull(); + expect(await cache.getCachedSearch(args, telemetry.query_class)).not.toBeNull(); + // A different replica records the review: no process-local invalidation here. + document.updated_at = "2026-10-17T00:00:00.000Z"; + document.metadata.document_status = "outdated"; + document.metadata = { ...document.metadata, governance_disposition: decision } as typeof document.metadata; + expect(await cache.getCachedAnswer(args, Date.now(), { indexingVersionAtRequestStart: start })).toBeNull(); + expect( + await cache.getCachedSearch(args, telemetry.query_class, [], { indexingVersionAtRequestStart: start }), + ).toBeNull(); + expect(await cache.getSharedCachedAnswer(args, Date.now(), { indexingVersionAtRequestStart: start })).toBeNull(); + expect( + await cache.getSharedCachedSearch(args, telemetry.query_class, [], { indexingVersionAtRequestStart: start }), + ).toMatchObject({ kind: "miss" }); + }, + ); + + it("keeps ordinary current-source hits and keeps the fresh-metadata refusal control", async () => { + const { cache, state } = await setup(); + await seedBoth(cache, state); + expect((await cache.getCachedAnswer(args, Date.now()))?.routingReason).toContain("answer_cache_hit"); + expect(await cache.getCachedSearch(args, telemetry.query_class)).not.toBeNull(); + expect((await cache.getSharedCachedAnswer(args, Date.now()))?.routingReason).toContain("shared_answer_cache_hit"); + expect(await cache.getSharedCachedSearch(args, telemetry.query_class)).toMatchObject({ kind: "hit" }); + const { buildGovernedAnswerClientResponse } = await import("../src/lib/answer-response"); + expect(buildGovernedAnswerClientResponse(answer()).refused).toBe(false); + const revoked = answer(); + revoked.sources[0].source_metadata!.document_status = "outdated"; + expect(buildGovernedAnswerClientResponse(revoked).refused).toBe(true); + }); + + it("composes the actual review route and invalidator safely when shared deletion returns an error", async () => { + const { cache, state, rpc } = await setup(); + await seedBoth(cache, state); + vi.doMock("@/lib/supabase/auth", () => ({ + AuthenticationError: class AuthenticationError extends Error {}, + requireAuthenticatedUser: async () => ({ id: ownerId }), + unauthorizedResponse: vi.fn(), + })); + vi.doMock("@/lib/api-rate-limit", () => ({ + consumeApiRateLimit: async () => ({ limited: false }), + rateLimitJsonResponse: vi.fn(), + })); + state.failDelete = true; + const { POST } = await import("../src/app/api/documents/[id]/reviews/route"); + const response = await POST( + new Request(`http://localhost/api/documents/${documentId}/reviews`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ decision: "decommissioned", reason: "Synthetic governance review" }), + }), + { params: Promise.resolve({ id: documentId }) }, + ); + expect(response.status, JSON.stringify(await response.json())).toBe(201); + expect(rpc).toHaveBeenCalledWith("record_source_review", expect.objectContaining({ p_decision: "decommissioned" })); + await vi.waitFor(() => expect(state.failedDeletes).toBeGreaterThan(0)); + expect(state.rows).toHaveLength(2); + expect(await cache.getSharedCachedAnswer(args, Date.now())).toBeNull(); + expect(await cache.getSharedCachedSearch(args, telemetry.query_class)).toMatchObject({ kind: "miss" }); + }); + + it("detects governance changes to a non-latest scoped document and ignores another owner's document", async () => { + const { cache, state, document } = await setup(); + const other = structuredClone(document); + other.id = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"; + other.updated_at = "2026-10-18T00:00:00.000Z"; + state.documents.push(other); + const globalArgs = { ...args, documentId: undefined }; + const before = await cache.cacheIndexingVersion(globalArgs, { forceRefresh: true }); + document.metadata.document_status = "outdated"; + expect(await cache.cacheIndexingVersion(globalArgs, { forceRefresh: true })).not.toBe(before); + const after = await cache.cacheIndexingVersion(globalArgs, { forceRefresh: true }); + state.documents.push({ ...structuredClone(other), id: "foreign-document", owner_id: "foreign-owner" }); + expect(await cache.cacheIndexingVersion(globalArgs, { forceRefresh: true })).toBe(after); + }); + + it.each(["answer", "search"])("rechecks revision after an in-flight shared %s read", async (kind) => { + const { cache, state, document } = await setup(); + await seedBoth(cache, state); + const gate = deferred(); + state.sharedReadGate = gate.promise; + const read = + kind === "answer" + ? cache.getSharedCachedAnswer(args, Date.now()) + : cache.getSharedCachedSearch(args, telemetry.query_class); + await vi.waitFor(() => expect(state.sharedReads).toBe(1)); + document.metadata.document_status = "outdated"; + document.updated_at = "2026-10-17T00:00:00.000Z"; + gate.resolve(); + if (kind === "answer") expect(await read).toBeNull(); + else expect(await read).toMatchObject({ kind: "miss" }); + }); + + it.each(["answer", "search"])( + "cannot reuse a late shared %s write from a different replica after review", + async (kind) => { + const { cache, state, document } = await setup(); + const start = await cache.cacheIndexingVersion(args, { forceRefresh: true }); + const gate = deferred(); + state.sharedInsertGate = gate.promise; + if (kind === "answer") await cache.setCachedAnswer(args, answer(), { indexingVersionAtRetrievalStart: start }); + else await cache.setCachedSearch(args, [source()], telemetry, [], { indexingVersionAtRetrievalStart: start }); + await vi.waitFor(() => expect(state.pendingInserts).toBe(1)); + document.metadata.document_status = "outdated"; + document.updated_at = "2026-10-17T00:00:00.000Z"; + gate.resolve(); + await vi.waitFor(() => expect(state.inserts).toBe(1)); + expect(await cache.getSharedCachedAnswer(args, Date.now())).toBeNull(); + expect(await cache.getSharedCachedSearch(args, telemetry.query_class)).toMatchObject({ kind: "miss" }); + expect(await cache.getCachedAnswer(args, Date.now())).toBeNull(); + await cache.setCachedSearch(args, [source()], telemetry, [], { indexingVersionAtRetrievalStart: start }); + expect(await cache.getCachedSearch(args, telemetry.query_class)).toBeNull(); + }, + ); + + it.each(["answer", "search"])("fences an in-flight local %s read against same-process invalidation", async (kind) => { + const { cache, state } = await setup(); + await seedBoth(cache, state); + const gate = deferred(); + state.revisionReadGate = gate.promise; + const read = + kind === "answer" ? cache.getCachedAnswer(args, Date.now()) : cache.getCachedSearch(args, telemetry.query_class); + // No document mutation: the local epoch must fence mutations outside documents. + cache.invalidateRagCachesForOwner(ownerId); + gate.resolve(); + expect(await read).toBeNull(); + }); + + it("changes identity when a scoped document leaves the indexed corpus or is removed", async () => { + const { cache, state, document } = await setup(); + const before = await cache.cacheIndexingVersion(args); + document.status = "failed"; + const failed = await cache.cacheIndexingVersion(args); + expect(failed).not.toBe(before); + state.documents = []; + const removed = await cache.cacheIndexingVersion(args); + expect(removed).not.toBe(failed); + expect(await cache.cacheIndexingVersion(args)).toBe(removed); + }); + + it.each(["before_waiter", "while_waiting", "epoch_invalidation"])( + "does not deliver an older coalesced answer when review commits %s", + async (timing) => { + const { cache, document } = await setup(); + const { withRagProgrammeRollout } = await import("../src/lib/rag/rag-rollout"); + const { answerQuestionWithScope } = await import("../src/lib/rag/rag"); + const request = withRagProgrammeRollout({ ...args, logQuery: false }); + await cache.setCachedAnswer(request, answer()); + const originReady = deferred(); + const gate = deferred(); + let waiterJoined = false; + let waiterObservedProgress = false; + const origin = answerQuestionWithScope({ + ...request, + onProgress: async (progress) => { + if (progress.stage === "cached" && progress.reason?.includes("answer_cache_hit")) { + originReady.resolve(); + await gate.promise; + } + }, + }); + await originReady.promise; + const reviewAndSeedCurrentAnswer = async () => { + if (timing === "epoch_invalidation") cache.invalidateRagCachesForOwner(ownerId); + else { + document.metadata.document_status = "outdated"; + document.updated_at = "2026-10-17T00:00:00.000Z"; + } + await cache.setCachedAnswer(request, { + answer: "Fresh evidence is required after source review.", + grounded: false, + confidence: "unsupported", + sources: [], + citations: [], + }); + }; + if (timing === "before_waiter") await reviewAndSeedCurrentAnswer(); + const waiter = answerQuestionWithScope({ + ...request, + onProgress: (progress) => { + waiterObservedProgress = true; + waiterJoined ||= progress.reason?.includes("answer_inflight_coalesced") === true; + }, + }); + try { + await vi.waitFor(() => expect(timing !== "before_waiter" ? waiterJoined : waiterObservedProgress).toBe(true)); + if (timing !== "before_waiter") await reviewAndSeedCurrentAnswer(); + } finally { + gate.resolve(); + } + const delivered = await waiter; + await origin; + expect(delivered.sources).toEqual([]); + expect(delivered.answer).not.toBe(answer().answer); + expect(delivered.routingReason).not.toContain("answer_inflight_coalesced"); + }, + ); + + it.each(["missing", "malformed"])("fails cold when the durable revision RPC is %s, then recovers", async (mode) => { + const { cache, state } = await setup(); + await seedBoth(cache, state); + state.failRevision = mode === "missing"; + state.malformedRevision = mode === "malformed"; + expect(await cache.getCachedAnswer(args, Date.now())).toBeNull(); + expect(await cache.getCachedSearch(args, telemetry.query_class)).toBeNull(); + expect(await cache.getSharedCachedAnswer(args, Date.now())).toBeNull(); + expect(await cache.getSharedCachedSearch(args, telemetry.query_class)).toMatchObject({ kind: "miss" }); + await cache.setCachedAnswer({ ...args, query: "unavailable answer write" }, answer()); + await cache.setCachedSearch({ ...args, query: "unavailable search write" }, [source()], telemetry); + expect(state.inserts).toBe(2); + state.failRevision = false; + state.malformedRevision = false; + expect(await cache.getCachedAnswer(args, Date.now())).not.toBeNull(); + }); + + it("does not join an existing answer when its durable revision cannot be read", async () => { + vi.stubEnv("RAG_PROVIDER_MODE", "offline"); + const { cache, state } = await setup(); + const { withRagProgrammeRollout } = await import("../src/lib/rag/rag-rollout"); + const { answerQuestionWithScope } = await import("../src/lib/rag/rag"); + const request = withRagProgrammeRollout({ ...args, logQuery: false }); + await cache.setCachedAnswer(request, answer()); + const originReady = deferred(); + const gate = deferred(); + const origin = answerQuestionWithScope({ + ...request, + onProgress: async (progress) => { + if (progress.stage === "cached" && progress.reason?.includes("answer_cache_hit")) { + originReady.resolve(); + await gate.promise; + } + }, + }); + await originReady.promise; + state.failRevision = true; + state.emptyRetrieval = true; + let waiterStarted = false; + const waiter = answerQuestionWithScope({ + ...request, + onProgress: (progress) => { + waiterStarted ||= + progress.stage !== "cached" || progress.reason?.includes("answer_inflight_coalesced") === true; + }, + }); + try { + await vi.waitFor(() => expect(waiterStarted).toBe(true)); + } finally { + gate.resolve(); + } + const delivered = await waiter; + await origin; + expect(delivered.sources).toEqual([]); + expect(delivered.routingReason).not.toContain("answer_inflight_coalesced"); + expect(await cache.answerInflightCacheKey(request)).toBeNull(); + }); +}); diff --git a/tests/rag-cache-indexing-stamp.test.ts b/tests/rag-cache-indexing-stamp.test.ts index 8f60b42c2b..e3f9a861c0 100644 --- a/tests/rag-cache-indexing-stamp.test.ts +++ b/tests/rag-cache-indexing-stamp.test.ts @@ -1,3 +1,4 @@ +import { withMockRagScopeRevision } from "./rag-cache-test-revision"; /** * The indexing-stamp corpus-staleness guard (audit L21). * @@ -27,42 +28,43 @@ type DocumentsResult = { data: unknown; error: unknown }; function mockDocumentsQuery(result: () => DocumentsResult) { vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - from: vi.fn((table: string) => { - if (table !== "documents") { - return { - delete: vi.fn(() => ({ - eq: vi.fn(function eq(this: unknown) { - return this; - }), - is: vi.fn(function is(this: unknown) { - return this; - }), - in: vi.fn(function inFilter(this: unknown) { - return this; - }), - then: (onfulfilled?: (value: { data: null; error: null }) => unknown) => - Promise.resolve({ data: null, error: null }).then(onfulfilled), - })), - insert: vi.fn(async () => ({ data: null, error: null })), - select: vi.fn(() => ({ - eq: vi.fn(function eq(this: unknown) { - return this; - }), - then: (onfulfilled?: (value: { data: null; error: null }) => unknown) => - Promise.resolve({ data: null, error: null }).then(onfulfilled), - })), - }; - } - const builder: Record = {}; - for (const method of ["select", "eq", "is", "or", "in", "order", "limit", "abortSignal"]) { - builder[method] = vi.fn(() => builder); - } - builder.then = (onfulfilled?: (value: DocumentsResult) => unknown) => - Promise.resolve(result()).then(onfulfilled); - return builder; + createAdminClient: () => + withMockRagScopeRevision({ + from: vi.fn((table: string) => { + if (table !== "documents") { + return { + delete: vi.fn(() => ({ + eq: vi.fn(function eq(this: unknown) { + return this; + }), + is: vi.fn(function is(this: unknown) { + return this; + }), + in: vi.fn(function inFilter(this: unknown) { + return this; + }), + then: (onfulfilled?: (value: { data: null; error: null }) => unknown) => + Promise.resolve({ data: null, error: null }).then(onfulfilled), + })), + insert: vi.fn(async () => ({ data: null, error: null })), + select: vi.fn(() => ({ + eq: vi.fn(function eq(this: unknown) { + return this; + }), + then: (onfulfilled?: (value: { data: null; error: null }) => unknown) => + Promise.resolve({ data: null, error: null }).then(onfulfilled), + })), + }; + } + const builder: Record = {}; + for (const method of ["select", "eq", "is", "or", "in", "order", "limit", "abortSignal"]) { + builder[method] = vi.fn(() => builder); + } + builder.then = (onfulfilled?: (value: DocumentsResult) => unknown) => + Promise.resolve(result()).then(onfulfilled); + return builder; + }), }), - }), })); } @@ -85,7 +87,7 @@ describe("indexing-stamp error is not an empty corpus (L21)", () => { expect(errorStamp).not.toBe(emptyStamp); expect(errorStamp).toContain("index-stamp-unavailable"); - expect(emptyStamp).toContain("no-indexed-documents"); + expect(emptyStamp).toContain("rag-cache-scope-v1:"); }); it("does not write an answer to the cache under an unavailable stamp", async () => { diff --git a/tests/rag-cache-invalidation.test.ts b/tests/rag-cache-invalidation.test.ts index 7f29486f65..b61df96843 100644 --- a/tests/rag-cache-invalidation.test.ts +++ b/tests/rag-cache-invalidation.test.ts @@ -1,3 +1,4 @@ +import { withMockRagScopeRevision, mockRagScopeRevision } from "./rag-cache-test-revision"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { SearchChunksArgs, SearchTelemetry } from "../src/lib/rag/rag-contracts"; import type { RagAnswer, SearchResult } from "../src/lib/types"; @@ -149,7 +150,7 @@ function installAnswerCacheEntrypointHarness( data: name.endsWith("_v3") && options.admittedSource ? [options.admittedSource] : [], error: null, })); - vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => ({ from, rpc }) })); + vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => withMockRagScopeRevision({ from, rpc }) })); return { from, rpc, insertedRows, state }; } @@ -337,8 +338,8 @@ describe("RAG cache invalidation", () => { const { ragCacheDependencyVersion } = await import("../src/lib/rag/rag-cache"); // Main still writes the unsafe grounded/non-review_only shape under v24; extractive // review fallback lacks generation_fallback:, so getSharedCachedAnswer will not evict. - // v25 is the intentional invalidation — staying on v24 would reintroduce the blocker. - expect(ragCacheDependencyVersion).toBe("rag-cache-v25"); + // v26 retains v25's intentional invalidation and adds governance freshness. Staying on v24 would reintroduce the blocker. + expect(ragCacheDependencyVersion).toBe("rag-cache-v26"); expect(ragCacheDependencyVersion).not.toBe("rag-cache-v24"); }); @@ -540,7 +541,7 @@ describe("RAG cache invalidation", () => { }, publicSiteChangeEpoch: "1", pendingPublicSiteChangeCount: 0, - documentIndexGeneration: "index-v1", + documentIndexGeneration: "test-rag-version:rag-cache-scope-v1:" + "0".repeat(32), sourcePolicyVersion: "policy-v1", rolloutVersion: "rollout-v1", }, @@ -552,7 +553,7 @@ describe("RAG cache invalidation", () => { canaryBasisPoints: 10000, queryPlanVersion: "v1", sourcePolicyVersion: "policy-v1", - indexGeneration: "index-v1", + indexGeneration: "test-rag-version:rag-cache-scope-v1:" + "0".repeat(32), publicSiteContentReleaseId: args.ragRequestContext.snapshot.publicSiteContent.releaseId, publicSiteContentStaticManifestDigest: "a".repeat(64), publicSiteContentReleaseDigest: "c".repeat(64), @@ -680,7 +681,7 @@ describe("RAG cache invalidation", () => { expect((await cache.getCachedAnswer(args, Date.now()))?.answer).toBe("variant-" + index); expect((await cache.getSharedCachedAnswer(args, Date.now()))?.answer).toBe("variant-" + index); } - expect(rows.every((row) => row.dependency_version === "rag-cache-v25")).toBe(true); + expect(rows.every((row) => row.dependency_version === "rag-cache-v26")).toBe(true); }); it("P08C distinguishes full request suffixes in every answer identity without retaining request text", async () => { @@ -782,7 +783,7 @@ describe("RAG cache invalidation", () => { const from = vi.fn(() => { throw new Error("shadow cache writes must not touch storage"); }); - vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => ({ from }) })); + vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => withMockRagScopeRevision({ from }) })); const { getCachedAnswer, setCachedAnswer, setCachedSearch } = await import("../src/lib/rag/rag-cache"); const args = { @@ -836,9 +837,9 @@ describe("RAG cache invalidation", () => { // Ledger #ZK460W: must stay ahead of main's v24 while that namespace still holds // unsafe grounded/non-review_only review-fallback rows that lack generation_fallback:. - expect(ragCacheDependencyVersion).toBe("rag-cache-v25"); + expect(ragCacheDependencyVersion).toBe("rag-cache-v26"); expect(ragCacheDependencyVersion).not.toBe("rag-cache-v24"); - expect(scopedAnswerCacheKey(legacyV1)).toMatch(/^rag-cache-v25\|/); + expect(scopedAnswerCacheKey(legacyV1)).toMatch(/^rag-cache-v26\|/); expect(scopedAnswerCacheKey(legacyV1)).not.toBe(scopedAnswerCacheKey(shadowV1)); expect(scopedAnswerCacheKey(shadowV1)).not.toBe(scopedAnswerCacheKey(shadowV2)); const searchKeys = [legacyV1, shadowV1, shadowV2].map((args) => @@ -851,11 +852,12 @@ describe("RAG cache invalidation", () => { it("removes only the matching hashed site-aware answer and in-flight identities", async () => { vi.resetModules(); vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - from: () => ({ - delete: () => new DeleteQuery([]), + createAdminClient: () => + withMockRagScopeRevision({ + from: () => ({ + delete: () => new DeleteQuery([]), + }), }), - }), })); const { withRagRequestContext } = await import("../src/lib/rag/rag-context-snapshot"); @@ -905,16 +907,17 @@ describe("RAG cache invalidation", () => { const calls: FilterCall[][] = []; vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - from: vi.fn((table: string) => ({ - delete: vi.fn(() => { - expect(table).toBe("rag_response_cache"); - const queryCalls: FilterCall[] = []; - calls.push(queryCalls); - return new DeleteQuery(queryCalls); - }), - })), - }), + createAdminClient: () => + withMockRagScopeRevision({ + from: vi.fn((table: string) => ({ + delete: vi.fn(() => { + expect(table).toBe("rag_response_cache"); + const queryCalls: FilterCall[] = []; + calls.push(queryCalls); + return new DeleteQuery(queryCalls); + }), + })), + }), })); const { invalidateRagCachesForDocumentMutation } = await import("../src/lib/rag/rag"); @@ -946,41 +949,42 @@ describe("RAG cache invalidation", () => { const indexingVersionGate = new Promise((resolve) => { releaseIndexingVersion = resolve; }); - const indexingStamp = "rag-deep-memory-v2:doc-1:2026-07-01T00:00:00.000Z:"; + const indexingStamp = `rag-deep-memory-v2:${mockRagScopeRevision([{ id: "doc-1", updated_at: "2026-07-01T00:00:00.000Z", metadata: {} }])}`; vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - from: vi.fn((table: string) => { - if (table === "documents") { - const builder = { - select: vi.fn(() => builder), - eq: vi.fn(() => builder), - is: vi.fn(() => builder), - or: vi.fn(() => builder), - in: vi.fn(() => builder), - order: vi.fn(() => builder), - limit: vi.fn(() => builder), - abortSignal: vi.fn(() => builder), - then: (onfulfilled?: (value: { data: unknown; error: null }) => unknown) => - indexingVersionGate - .then(() => - Promise.resolve({ - data: [{ id: "doc-1", updated_at: "2026-07-01T00:00:00.000Z", metadata: {} }], - error: null, - }), - ) - .then(onfulfilled), - }; - return builder; - } + createAdminClient: () => + withMockRagScopeRevision({ + from: vi.fn((table: string) => { + if (table === "documents") { + const builder = { + select: vi.fn(() => builder), + eq: vi.fn(() => builder), + is: vi.fn(() => builder), + or: vi.fn(() => builder), + in: vi.fn(() => builder), + order: vi.fn(() => builder), + limit: vi.fn(() => builder), + abortSignal: vi.fn(() => builder), + then: (onfulfilled?: (value: { data: unknown; error: null }) => unknown) => + indexingVersionGate + .then(() => + Promise.resolve({ + data: [{ id: "doc-1", updated_at: "2026-07-01T00:00:00.000Z", metadata: {} }], + error: null, + }), + ) + .then(onfulfilled), + }; + return builder; + } - const deleteCalls: FilterCall[] = []; - return { - delete: vi.fn(() => new DeleteQuery(deleteCalls)), - insert: vi.fn(async () => ({ data: null, error: null })), - }; + const deleteCalls: FilterCall[] = []; + return { + delete: vi.fn(() => new DeleteQuery(deleteCalls)), + insert: vi.fn(async () => ({ data: null, error: null })), + }; + }), }), - }), })); const { getCachedAnswer, invalidateRagCachesForOwner, setCachedAnswer } = await import("../src/lib/rag/rag-cache"); @@ -995,7 +999,7 @@ describe("RAG cache invalidation", () => { }); // Simulate a table-fact / review mutation while the deferred promotion awaits - // its forced indexing-version read. Review metadata does not change that stamp. + // its forced document-revision read. A table-fact mutation is outside that revision. invalidateRagCachesForOwner(ownerId); releaseIndexingVersion?.(); await promotion; @@ -1015,41 +1019,42 @@ describe("RAG cache invalidation", () => { const indexingVersionGate = new Promise((resolve) => { releaseIndexingVersion = resolve; }); - const indexingStamp = "rag-deep-memory-v2:doc-1:2026-07-01T00:00:00.000Z:"; + const indexingStamp = `rag-deep-memory-v2:${mockRagScopeRevision([{ id: "doc-1", updated_at: "2026-07-01T00:00:00.000Z", metadata: {} }])}`; vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - from: vi.fn((table: string) => { - if (table === "documents") { - const builder = { - select: vi.fn(() => builder), - eq: vi.fn(() => builder), - is: vi.fn(() => builder), - or: vi.fn(() => builder), - in: vi.fn(() => builder), - order: vi.fn(() => builder), - limit: vi.fn(() => builder), - abortSignal: vi.fn(() => builder), - then: (onfulfilled?: (value: { data: unknown; error: null }) => unknown) => - indexingVersionGate - .then(() => - Promise.resolve({ - data: [{ id: "doc-1", updated_at: "2026-07-01T00:00:00.000Z", metadata: {} }], - error: null, - }), - ) - .then(onfulfilled), - }; - return builder; - } + createAdminClient: () => + withMockRagScopeRevision({ + from: vi.fn((table: string) => { + if (table === "documents") { + const builder = { + select: vi.fn(() => builder), + eq: vi.fn(() => builder), + is: vi.fn(() => builder), + or: vi.fn(() => builder), + in: vi.fn(() => builder), + order: vi.fn(() => builder), + limit: vi.fn(() => builder), + abortSignal: vi.fn(() => builder), + then: (onfulfilled?: (value: { data: unknown; error: null }) => unknown) => + indexingVersionGate + .then(() => + Promise.resolve({ + data: [{ id: "doc-1", updated_at: "2026-07-01T00:00:00.000Z", metadata: {} }], + error: null, + }), + ) + .then(onfulfilled), + }; + return builder; + } - const deleteCalls: FilterCall[] = []; - return { - delete: vi.fn(() => new DeleteQuery(deleteCalls)), - insert: vi.fn(async () => ({ data: null, error: null })), - }; + const deleteCalls: FilterCall[] = []; + return { + delete: vi.fn(() => new DeleteQuery(deleteCalls)), + insert: vi.fn(async () => ({ data: null, error: null })), + }; + }), }), - }), })); const { getCachedAnswer, invalidateRagCachesForOwner, setCachedAnswer } = await import("../src/lib/rag/rag-cache"); diff --git a/tests/rag-cache-lru.test.ts b/tests/rag-cache-lru.test.ts index e086162e2e..f626740294 100644 --- a/tests/rag-cache-lru.test.ts +++ b/tests/rag-cache-lru.test.ts @@ -1,3 +1,4 @@ +import { withMockRagScopeRevision } from "./rag-cache-test-revision"; /** * Process-local answer-cache eviction order (audit L134). * @@ -27,42 +28,43 @@ type DocumentsResult = { data: unknown; error: unknown }; function mockDocumentsQuery(result: () => DocumentsResult) { vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - from: vi.fn((table: string) => { - if (table !== "documents") { - return { - delete: vi.fn(() => ({ - eq: vi.fn(function eq(this: unknown) { - return this; - }), - is: vi.fn(function is(this: unknown) { - return this; - }), - in: vi.fn(function inFilter(this: unknown) { - return this; - }), - then: (onfulfilled?: (value: { data: null; error: null }) => unknown) => - Promise.resolve({ data: null, error: null }).then(onfulfilled), - })), - insert: vi.fn(async () => ({ data: null, error: null })), - select: vi.fn(() => ({ - eq: vi.fn(function eq(this: unknown) { - return this; - }), - then: (onfulfilled?: (value: { data: null; error: null }) => unknown) => - Promise.resolve({ data: null, error: null }).then(onfulfilled), - })), - }; - } - const builder: Record = {}; - for (const method of ["select", "eq", "is", "or", "in", "order", "limit", "abortSignal"]) { - builder[method] = vi.fn(() => builder); - } - builder.then = (onfulfilled?: (value: DocumentsResult) => unknown) => - Promise.resolve(result()).then(onfulfilled); - return builder; + createAdminClient: () => + withMockRagScopeRevision({ + from: vi.fn((table: string) => { + if (table !== "documents") { + return { + delete: vi.fn(() => ({ + eq: vi.fn(function eq(this: unknown) { + return this; + }), + is: vi.fn(function is(this: unknown) { + return this; + }), + in: vi.fn(function inFilter(this: unknown) { + return this; + }), + then: (onfulfilled?: (value: { data: null; error: null }) => unknown) => + Promise.resolve({ data: null, error: null }).then(onfulfilled), + })), + insert: vi.fn(async () => ({ data: null, error: null })), + select: vi.fn(() => ({ + eq: vi.fn(function eq(this: unknown) { + return this; + }), + then: (onfulfilled?: (value: { data: null; error: null }) => unknown) => + Promise.resolve({ data: null, error: null }).then(onfulfilled), + })), + }; + } + const builder: Record = {}; + for (const method of ["select", "eq", "is", "or", "in", "order", "limit", "abortSignal"]) { + builder[method] = vi.fn(() => builder); + } + builder.then = (onfulfilled?: (value: DocumentsResult) => unknown) => + Promise.resolve(result()).then(onfulfilled); + return builder; + }), }), - }), })); } diff --git a/tests/rag-cache-test-revision.ts b/tests/rag-cache-test-revision.ts new file mode 100644 index 0000000000..13cce47a26 --- /dev/null +++ b/tests/rag-cache-test-revision.ts @@ -0,0 +1,67 @@ +import { createHash } from "node:crypto"; + +/** Fixture adapter for suites that already model document reads, never a production fallback. */ +export function mockRagScopeRevision(rows: unknown) { + return `rag-cache-scope-v1:${createHash("md5").update(JSON.stringify(rows)).digest("hex")}`; +} + +type MockQuery = PromiseLike<{ data: unknown; error: unknown }> & { + select: (columns: string) => MockQuery; + eq: (column: string, value: unknown) => MockQuery; + is: (column: string, value: unknown) => MockQuery; + or: (filter: string) => MockQuery; + in: (column: string, value: unknown) => MockQuery; + order: (column: string, options: unknown) => MockQuery; + limit: (limit: number) => MockQuery; + abortSignal?: (signal: AbortSignal) => MockQuery; +}; + +export function withMockRagScopeRevision(client: T, fixedRevision?: string) { + const storage = client as T & { from: (table: string) => MockQuery; rpc?: (...args: unknown[]) => unknown }; + return { + ...client, + rpc(name: string, params: Record) { + if (name !== "rag_cache_scope_revision_v1") return storage.rpc?.(name, params); + if (fixedRevision) { + const response = { + abortSignal: () => response, + then(fulfilled?: (result: { data: string; error: null }) => unknown) { + return Promise.resolve({ data: fixedRevision, error: null }).then(fulfilled); + }, + }; + return response; + } + // These suites model one/current document. The dedicated F13 suite models + // complete-scope DB revision semantics and matching owner/document filters. + let query = storage + .from("documents") + .select("id,updated_at,metadata") + .eq("status", "indexed") + .order("updated_at", { ascending: false }) + .limit(1); + const owner = params.p_owner_id; + query = owner + ? params.p_include_public + ? query.or(`owner_id.eq.${owner},owner_id.is.null`) + : query.eq("owner_id", owner) + : query.is("owner_id", null); + const ids = params.p_document_ids as string[] | null; + if (ids?.length) query = query.in("id", ids); + const response = { + abortSignal(signal: AbortSignal) { + query = query.abortSignal?.(signal) ?? query; + return response; + }, + then( + fulfilled?: (result: { data: string | null; error: unknown }) => unknown, + rejected?: (reason: unknown) => unknown, + ) { + return Promise.resolve(query) + .then(({ data, error }) => ({ data: error ? null : mockRagScopeRevision(data), error })) + .then(fulfilled, rejected); + }, + }; + return response; + }, + }; +} diff --git a/tests/rag-shared-cache.test.ts b/tests/rag-shared-cache.test.ts index b633b66a8e..f9ba53773a 100644 --- a/tests/rag-shared-cache.test.ts +++ b/tests/rag-shared-cache.test.ts @@ -1,3 +1,4 @@ +import { withMockRagScopeRevision } from "./rag-cache-test-revision"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { SearchTelemetry } from "../src/lib/rag/rag-contracts"; @@ -65,7 +66,7 @@ describe("shared RAG search cache", () => { const from = vi.fn(() => { throw new Error("shadow must not access answer cache"); }); - vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => ({ from }) })); + vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => withMockRagScopeRevision({ from }) })); const cache = await import("../src/lib/rag/rag-cache"); const args = { query: "What is clozapine?", ownerId, ragQueryPlanMode: "shadow" as const }; const answer = { @@ -128,9 +129,10 @@ describe("shared RAG search cache", () => { vi.doMock("@/lib/deep-memory", () => ({ ragDeepMemoryVersion: "test-rag-version" })); vi.doMock("@/lib/clinical-search", () => ({ buildClinicalTextSearchQuery: (query: string) => query.trim() })); vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - from: (table: string) => (table === "documents" ? documentBuilder : responseBuilder), - }), + createAdminClient: () => + withMockRagScopeRevision({ + from: (table: string) => (table === "documents" ? documentBuilder : responseBuilder), + }), })); const { getCachedSearch, setCachedSearch } = await import("../src/lib/rag/rag-cache"); @@ -229,9 +231,10 @@ describe("shared RAG search cache", () => { vi.doMock("@/lib/deep-memory", () => ({ ragDeepMemoryVersion: "test-rag-version" })); vi.doMock("@/lib/clinical-search", () => ({ buildClinicalTextSearchQuery: (query: string) => query.trim() })); vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - from: (table: string) => (table === "documents" ? documentBuilder : responseBuilder), - }), + createAdminClient: () => + withMockRagScopeRevision({ + from: (table: string) => (table === "documents" ? documentBuilder : responseBuilder), + }), })); const { withRagRequestContext } = await import("../src/lib/rag/rag-context-snapshot"); @@ -331,7 +334,10 @@ describe("shared RAG search cache", () => { })); vi.doMock("@/lib/deep-memory", () => ({ ragDeepMemoryVersion: "test-rag-version" })); vi.doMock("@/lib/clinical-search", () => ({ buildClinicalTextSearchQuery: (query: string) => query.trim() })); - vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => ({ from: () => builder }) })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: () => + withMockRagScopeRevision({ from: () => builder }, "rag-cache-scope-v1:" + "0".repeat(32)), + })); const { withRagRequestContext } = await import("../src/lib/rag/rag-context-snapshot"); const { getSharedCachedAnswer, getSharedCachedSearch } = await import("../src/lib/rag/rag-cache"); @@ -360,7 +366,9 @@ describe("shared RAG search cache", () => { ragContextSnapshotInput: snapshotInput, }); - await getSharedCachedSearch(request, "table_threshold", [], { indexingVersionAtRequestStart: "index-v1" }); + await getSharedCachedSearch(request, "table_threshold", [], { + indexingVersionAtRequestStart: "test-rag-version:rag-cache-scope-v1:" + "0".repeat(32), + }); const firstSearchQuery = selectors.find( (selector) => selector.method === "eq" && selector.column === "normalized_query", )?.value; @@ -368,7 +376,9 @@ describe("shared RAG search cache", () => { expect(selectors).not.toContainEqual({ method: "eq", column: "owner_id", value: ownerId }); selectors.length = 0; - await getSharedCachedAnswer(request, Date.now(), { indexingVersionAtRequestStart: "index-v1" }); + await getSharedCachedAnswer(request, Date.now(), { + indexingVersionAtRequestStart: "test-rag-version:rag-cache-scope-v1:" + "0".repeat(32), + }); expect(selectors).toContainEqual({ method: "eq", column: "owner_id", value: ownerId }); selectors.length = 0; @@ -378,7 +388,9 @@ describe("shared RAG search cache", () => { accessScope: request.accessScope, ragContextSnapshotInput: { ...snapshotInput, publicSiteChangeEpoch: "2" }, }); - await getSharedCachedSearch(nextRequest, "table_threshold", [], { indexingVersionAtRequestStart: "index-v1" }); + await getSharedCachedSearch(nextRequest, "table_threshold", [], { + indexingVersionAtRequestStart: "test-rag-version:rag-cache-scope-v1:" + "0".repeat(32), + }); const nextSearchQuery = selectors.find( (selector) => selector.method === "eq" && selector.column === "normalized_query", )?.value; @@ -404,32 +416,35 @@ describe("shared RAG search cache", () => { buildClinicalTextSearchQuery: (query: string) => query.trim(), })); vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - from: (table: string) => { - const builder = { - select: () => builder, - eq: () => builder, - is: () => builder, - in: () => builder, - or: () => builder, - gt: () => builder, - order: () => builder, - limit: () => builder, - maybeSingle: async () => { - expect(table).toBe("rag_response_cache"); - sharedCacheReads += 1; - return { data: null, error: null }; - }, - then: (resolve: (value: { data: unknown[]; error: null }) => unknown) => - Promise.resolve({ - data: - table === "documents" ? [{ id: "doc-1", updated_at: "2026-07-14T00:00:00.000Z", metadata: {} }] : [], - error: null, - }).then(resolve), - }; - return builder; - }, - }), + createAdminClient: () => + withMockRagScopeRevision({ + from: (table: string) => { + const builder = { + select: () => builder, + eq: () => builder, + is: () => builder, + in: () => builder, + or: () => builder, + gt: () => builder, + order: () => builder, + limit: () => builder, + maybeSingle: async () => { + expect(table).toBe("rag_response_cache"); + sharedCacheReads += 1; + return { data: null, error: null }; + }, + then: (resolve: (value: { data: unknown[]; error: null }) => unknown) => + Promise.resolve({ + data: + table === "documents" + ? [{ id: "doc-1", updated_at: "2026-07-14T00:00:00.000Z", metadata: {} }] + : [], + error: null, + }).then(resolve), + }; + return builder; + }, + }), })); const { getSharedCachedSearch } = await import("../src/lib/rag/rag-cache"); @@ -460,39 +475,42 @@ describe("shared RAG search cache", () => { buildClinicalTextSearchQuery: (query: string) => query.trim(), })); vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - from: (table: string) => { - const builder = { - select: () => builder, - eq: () => builder, - is: () => builder, - in: () => builder, - or: () => builder, - gt: () => builder, - order: () => builder, - limit: () => builder, - maybeSingle: async () => { - if (table !== "rag_response_cache") return { data: null, error: null }; - return { - data: { - payload: { - results: [], - telemetry: { query_class: "unsupported_or_general", corpus_grounding: "in_corpus_topic" }, + createAdminClient: () => + withMockRagScopeRevision({ + from: (table: string) => { + const builder = { + select: () => builder, + eq: () => builder, + is: () => builder, + in: () => builder, + or: () => builder, + gt: () => builder, + order: () => builder, + limit: () => builder, + maybeSingle: async () => { + if (table !== "rag_response_cache") return { data: null, error: null }; + return { + data: { + payload: { + results: [], + telemetry: { query_class: "unsupported_or_general", corpus_grounding: "in_corpus_topic" }, + }, }, - }, - error: null, - }; - }, - then: (resolve: (value: { data: unknown[]; error: null }) => unknown) => - Promise.resolve({ - data: - table === "documents" ? [{ id: "doc-1", updated_at: "2026-07-14T00:00:00.000Z", metadata: {} }] : [], - error: null, - }).then(resolve), - }; - return builder; - }, - }), + error: null, + }; + }, + then: (resolve: (value: { data: unknown[]; error: null }) => unknown) => + Promise.resolve({ + data: + table === "documents" + ? [{ id: "doc-1", updated_at: "2026-07-14T00:00:00.000Z", metadata: {} }] + : [], + error: null, + }).then(resolve), + }; + return builder; + }, + }), })); const { getSharedCachedSearch } = await import("../src/lib/rag/rag-cache"); @@ -543,11 +561,14 @@ describe("shared RAG search cache", () => { })); vi.doMock("@/lib/deep-memory", () => ({ ragDeepMemoryVersion: "test-rag-version" })); vi.doMock("@/lib/clinical-search", () => ({ buildClinicalTextSearchQuery: (query: string) => query.trim() })); - vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => ({ from: () => builder }) })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: () => + withMockRagScopeRevision({ from: () => builder }, "rag-cache-scope-v1:" + "0".repeat(32)), + })); const { getSharedCachedSearch } = await import("../src/lib/rag/rag-cache"); const result = await getSharedCachedSearch({ query: "lithium monitoring", ownerId }, undefined, [], { - indexingVersionAtRequestStart: "index-v1", + indexingVersionAtRequestStart: "test-rag-version:rag-cache-scope-v1:" + "0".repeat(32), }); expect(result?.kind).toBe("hit"); @@ -603,12 +624,13 @@ describe("shared RAG search cache", () => { maybeSingle: async () => ({ data: null, error: null }), }; vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - from: (table: string) => { - if (table === "rag_response_cache") return shared.builder; - return docBuilder; - }, - }), + createAdminClient: () => + withMockRagScopeRevision({ + from: (table: string) => { + if (table === "rag_response_cache") return shared.builder; + return docBuilder; + }, + }), })); const { getSharedCachedAnswer } = await import("../src/lib/rag/rag-cache"); @@ -686,9 +708,10 @@ describe("shared RAG search cache", () => { buildClinicalTextSearchQuery: (query: string) => query.trim(), })); vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - from: (table: string) => (table === "documents" ? failingDocBuilder : cacheTableBuilder), - }), + createAdminClient: () => + withMockRagScopeRevision({ + from: (table: string) => (table === "documents" ? failingDocBuilder : cacheTableBuilder), + }), })); const { diff --git a/tests/rag-site-content-freshness.test.ts b/tests/rag-site-content-freshness.test.ts index 100426bea0..1d1b3e2c0c 100644 --- a/tests/rag-site-content-freshness.test.ts +++ b/tests/rag-site-content-freshness.test.ts @@ -1,3 +1,4 @@ +import { withMockRagScopeRevision, mockRagScopeRevision } from "./rag-cache-test-revision"; import { createHash } from "node:crypto"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -223,15 +224,16 @@ function installDeferredCacheHarness( vi.doMock("@/lib/deep-memory", () => ({ ragDeepMemoryVersion: "test-rag-version" })); vi.doMock("@/lib/clinical-search", () => ({ buildClinicalTextSearchQuery: (query: string) => query.trim() })); vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ - from: (table: string) => { - if (table === "documents") { - documentReads += 1; - return documentBuilder; - } - return responseBuilder; - }, - }), + createAdminClient: () => + withMockRagScopeRevision({ + from: (table: string) => { + if (table === "documents") { + documentReads += 1; + return documentBuilder; + } + return responseBuilder; + }, + }), })); return { @@ -679,7 +681,7 @@ describe("RAG request site-content snapshot", () => { expect(legacy.ragRequestContext.snapshot.publicSiteContent.state).toBe("disabled"); expect(legacy.ragRequestContext.snapshotCacheKey).toBe(""); const answerKey = ragCacheModule.scopedAnswerCacheKey(legacy); - expect(answerKey).toMatch(/^rag-cache-v25\|[0-9a-f]{64}\|answer-owner:[0-9a-f]{64}\|answer-request:[0-9a-f]{64}$/); + expect(answerKey).toMatch(/^rag-cache-v26\|[0-9a-f]{64}\|answer-owner:[0-9a-f]{64}\|answer-request:[0-9a-f]{64}$/); for (const privateValue of ["owner-a", "clozapine monitoring", generation]) { expect(answerKey).not.toContain(privateValue); } @@ -912,7 +914,8 @@ describe("site-aware RAG cache isolation", () => { vi.doMock("@/lib/deep-memory", () => ({ ragDeepMemoryVersion: "test-rag-version" })); vi.doMock("@/lib/clinical-search", () => ({ buildClinicalTextSearchQuery: (query: string) => query.trim() })); vi.doMock("@/lib/supabase/admin", () => ({ - createAdminClient: () => ({ from: () => makeBuilder() }), + createAdminClient: () => + withMockRagScopeRevision({ from: () => makeBuilder() }, "rag-cache-scope-v1:" + "0".repeat(32)), })); const cache = await import("../src/lib/rag/rag-cache"); const base = snapshotModule.withRagRequestContext({ @@ -922,14 +925,14 @@ describe("site-aware RAG cache isolation", () => { }); await cache.getSharedCachedSearch({ ...base, ownerId: "owner-a" }, "table_threshold", [], { - indexingVersionAtRequestStart: "index-v1", + indexingVersionAtRequestStart: "test-rag-version:rag-cache-scope-v1:" + "0".repeat(32), }); expect(selectors).toContainEqual({ kind: "is", column: "owner_id", value: null }); expect(selectors).not.toContainEqual({ kind: "eq", column: "owner_id", value: "owner-a" }); selectors.length = 0; await cache.getSharedCachedAnswer({ ...base, ownerId: "owner-a" }, Date.now(), { - indexingVersionAtRequestStart: "index-v1", + indexingVersionAtRequestStart: "test-rag-version:rag-cache-scope-v1:" + "0".repeat(32), }); expect(selectors).toContainEqual({ kind: "eq", column: "owner_id", value: "owner-a" }); }); @@ -980,7 +983,9 @@ describe("site-aware RAG cache isolation", () => { vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => { adminClients += 1; - return { from: (table: string) => (table === "documents" ? documentBuilder : responseBuilder) }; + return withMockRagScopeRevision({ + from: (table: string) => (table === "documents" ? documentBuilder : responseBuilder), + }); }, })); const cache = await import("../src/lib/rag/rag-cache"); @@ -1163,7 +1168,7 @@ describe("site-aware RAG cache isolation", () => { await write; await vi.waitFor(() => expect(harness.inserts).toHaveLength(1)); const hit = await cache.getCachedSearch(originalArgs, "table_threshold", ["variant-original"], { - indexingVersionAtRequestStart: "test-rag-version:document-original:2026-08-29T00:00:00.000Z:", + indexingVersionAtRequestStart: `test-rag-version:${mockRagScopeRevision([{ id: "document-original", updated_at: "2026-08-29T00:00:00.000Z", metadata: {} }])}`, }); expect(hit?.results[0]).toMatchObject({ id: "chunk-canary-1", @@ -1176,8 +1181,8 @@ describe("site-aware RAG cache isolation", () => { cache_kind: "search", scope_key: "public-only|document-original", normalized_query: expectedSharedQuery, - indexing_version: "test-rag-version:document-original:2026-08-29T00:00:00.000Z:", - dependency_version: "rag-cache-v25", + indexing_version: `test-rag-version:${mockRagScopeRevision([{ id: "document-original", updated_at: "2026-08-29T00:00:00.000Z", metadata: {} }])}`, + dependency_version: "rag-cache-v26", payload: { results: [ expect.objectContaining({ @@ -1263,7 +1268,7 @@ describe("site-aware RAG cache isolation", () => { await write; await vi.waitFor(() => expect(harness.inserts).toHaveLength(1)); const hit = await cache.getCachedAnswer(originalArgs, Date.now(), { - indexingVersionAtRequestStart: "test-rag-version:document-original:2026-08-29T00:00:00.000Z:", + indexingVersionAtRequestStart: `test-rag-version:${mockRagScopeRevision([{ id: "document-original", updated_at: "2026-08-29T00:00:00.000Z", metadata: {} }])}`, }); expect(hit).toMatchObject({ answer: "Original answer text.", @@ -1461,7 +1466,7 @@ describe("site-aware RAG cache isolation", () => { expect(harness.inserts).toHaveLength(0); expect( await cache.getCachedSearch({ ...request, signal: undefined }, "table_threshold", [], { - indexingVersionAtRequestStart: "test-rag-version:document-original:2026-08-29T00:00:00.000Z:", + indexingVersionAtRequestStart: `test-rag-version:${mockRagScopeRevision([{ id: "document-original", updated_at: "2026-08-29T00:00:00.000Z", metadata: {} }])}`, }), ).toBeNull(); }); diff --git a/tests/rag-tail-latency.test.ts b/tests/rag-tail-latency.test.ts index 9c4a7097ff..82dac9f68f 100644 --- a/tests/rag-tail-latency.test.ts +++ b/tests/rag-tail-latency.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { mockRagScopeRevision } from "./rag-cache-test-revision"; import type { SearchTelemetry } from "../src/lib/rag/rag-contracts"; import type { RagAnswer } from "../src/lib/types"; @@ -6,7 +7,7 @@ const ownerId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const ragVersion = "test-rag-version"; function indexingVersion(stamp: string) { - return `${ragVersion}:doc-1:${stamp}:`; + return `${ragVersion}:${mockRagScopeRevision([{ id: "doc-1", metadata: { indexed_at: "unchanged-index", governance: stamp } }])}`; } function baseTelemetry(overrides: Partial = {}): SearchTelemetry { @@ -26,17 +27,37 @@ type CacheHarness = ReturnType; function createCacheHarness() { let documentStamp = "request-start"; - let documentReads = 0; + let revisionReads = 0; let clientCreations = 0; let advanceClockOnSecondClient = false; + let rows: Array> = []; const sharedReadVersions: string[] = []; const inserts: Array> = []; function chain(table: string) { + if (table !== "rag_response_cache") throw new Error(`Unexpected cache table: ${table}`); const filters = new Map(); + let operation = "select"; + let inserted: Record | undefined; + const matches = (row: Record) => + [...filters].every(([key, value]) => + key === "expires_at" + ? String(row[key]) > String(value) + : Array.isArray(value) + ? value.includes(row[key]) + : row[key] === value, + ); const builder = { select: () => builder, - delete: () => builder, + delete: () => { + operation = "delete"; + return builder; + }, + insert: (row: Record) => { + operation = "insert"; + inserted = structuredClone(row); + return builder; + }, eq: (column: string, value: unknown) => { filters.set(column, value); return builder; @@ -45,27 +66,28 @@ function createCacheHarness() { filters.set(column, value); return builder; }, - in: () => builder, - or: () => builder, - gt: () => builder, + in: (column: string, value: unknown) => { + filters.set(column, value); + return builder; + }, + gt: (column: string, value: unknown) => { + filters.set(column, value); + return builder; + }, order: () => builder, limit: () => builder, maybeSingle: async () => { sharedReadVersions.push(String(filters.get("indexing_version"))); - return { data: null, error: null }; + const row = rows.find(matches); + return { data: row ? { payload: structuredClone(row.payload) } : null, error: null }; }, - then: ( - resolve: (value: { data: unknown[] | null; error: null }) => unknown, - reject?: (reason: unknown) => unknown, - ) => { - if (table === "documents") { - documentReads += 1; - return Promise.resolve({ - data: [{ id: "doc-1", updated_at: documentStamp, metadata: {} }], - error: null, - }).then(resolve, reject); + then: (resolve: (value: { data: null; error: null }) => unknown, reject?: (reason: unknown) => unknown) => { + if (operation === "delete") rows = rows.filter((row) => !matches(row)); + if (operation === "insert") { + rows.push(inserted!); + inserts.push(inserted!); } - return Promise.resolve({ data: [], error: null }).then(resolve, reject); + return Promise.resolve({ data: null, error: null }).then(resolve, reject); }, }; return builder; @@ -73,24 +95,21 @@ function createCacheHarness() { const createAdminClient = vi.fn(() => { clientCreations += 1; - if (advanceClockOnSecondClient && clientCreations === 2) { - vi.setSystemTime(new Date(Date.now() + 6_000)); - } + if (advanceClockOnSecondClient && clientCreations === 2) vi.setSystemTime(new Date(Date.now() + 6_000)); return { - from: (table: string) => ({ - ...chain(table), - insert: async (row: Record) => { - inserts.push(row); - return { data: null, error: null }; - }, + rpc: vi.fn(async (name: string) => { + if (name !== "rag_cache_scope_revision_v1") throw new Error(`Unexpected cache RPC: ${name}`); + revisionReads += 1; + return { data: indexingVersion(documentStamp).slice(ragVersion.length + 1), error: null }; }), + from: chain, }; }); return { createAdminClient, - get documentReads() { - return documentReads; + get revisionReads() { + return revisionReads; }, get sharedReadVersions() { return sharedReadVersions; @@ -101,7 +120,11 @@ function createCacheHarness() { setDocumentStamp(stamp: string) { documentStamp = stamp; }, - expireVersionBeforeSharedWrite() { + resetReads() { + revisionReads = 0; + sharedReadVersions.length = 0; + }, + advanceClockBeforeSharedWrite() { advanceClockOnSecondClient = true; }, }; @@ -173,66 +196,90 @@ describe("governed corpus latency shape", () => { }); describe("RAG cache request indexing version", () => { - it("reuses one request-start version across shared answer and search cache reads", async () => { + it("brackets real shared hits with fresh revisions and refuses a stale request-start hint", async () => { const harness = createCacheHarness(); const cache = await loadCache(harness); const requestVersion = indexingVersion("request-start"); const args = { query: "lithium monitoring", ownerId }; - - await cache.getSharedCachedAnswer(args, Date.now(), { indexingVersionAtRequestStart: requestVersion }); - await cache.getSharedCachedSearch(args, "medication_dose_risk", [], { - indexingVersionAtRequestStart: requestVersion, + await cache.setCachedAnswer( + args, + { answer: "Current cached evidence", grounded: false, confidence: "unsupported", citations: [], sources: [] }, + { indexingVersionAtRetrievalStart: requestVersion }, + ); + await cache.setCachedSearch(args, [], baseTelemetry({ query_class: "medication_dose_risk" }), [], { + indexingVersionAtRetrievalStart: requestVersion, }); + await vi.waitFor(() => expect(harness.inserts).toHaveLength(2)); + harness.resetReads(); + + expect( + (await cache.getSharedCachedAnswer(args, Date.now(), { indexingVersionAtRequestStart: requestVersion }))?.answer, + ).toBe("Current cached evidence"); + expect( + await cache.getSharedCachedSearch(args, "medication_dose_risk", [], { + indexingVersionAtRequestStart: requestVersion, + }), + ).toMatchObject({ kind: "hit" }); + expect(harness.revisionReads).toBe(4); + expect(harness.sharedReadVersions).toEqual([requestVersion, requestVersion]); - expect(harness.documentReads).toBe(0); + // Governance changes while indexed_at remains fixed. A caller's old hint may not bypass the read fence. + harness.setDocumentStamp("reviewed-after-request-start"); + expect( + await cache.getSharedCachedAnswer(args, Date.now(), { indexingVersionAtRequestStart: requestVersion }), + ).toBeNull(); + expect( + await cache.getSharedCachedSearch(args, "medication_dose_risk", [], { + indexingVersionAtRequestStart: requestVersion, + }), + ).toMatchObject({ kind: "miss" }); + expect(harness.revisionReads).toBe(6); expect(harness.sharedReadVersions).toEqual([requestVersion, requestVersion]); }); - it("uses the request-start version for a process-local search cache read", async () => { + it("refreshes a local search hit and rejects it when governance changes after request start", async () => { const harness = createCacheHarness(); const cache = await loadCache(harness); const args = { query: "lithium monitoring", ownerId }; const requestVersion = indexingVersion("request-start"); - await cache.setCachedSearch(args, [], baseTelemetry({ query_class: "medication_dose_risk" }), [], { indexingVersionAtRetrievalStart: requestVersion, }); - harness.setDocumentStamp("changed-after-request-start"); - await cache.cacheIndexingVersion(args, { forceRefresh: true }); - - const cached = await cache.getCachedSearch(args, "medication_dose_risk", [], { - indexingVersionAtRequestStart: requestVersion, - }); - - expect(cached?.telemetry.search_cache_hit).toBe(true); + expect( + (await cache.getCachedSearch(args, "medication_dose_risk", [], { indexingVersionAtRequestStart: requestVersion })) + ?.telemetry.search_cache_hit, + ).toBe(true); + expect(harness.revisionReads).toBe(2); + harness.setDocumentStamp("reviewed-after-request-start"); + expect( + await cache.getCachedSearch(args, "medication_dose_risk", [], { indexingVersionAtRequestStart: requestVersion }), + ).toBeNull(); + expect(harness.revisionReads).toBe(3); }); - it("keeps one forced write refresh, rejects changed indexing, and passes the refresh to the shared writer", async () => { + it("keeps one authoritative write refresh, rejects changed governance, and persists that exact revision", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-07-14T00:00:00.000Z")); const harness = createCacheHarness(); harness.setDocumentStamp("changed"); const cache = await loadCache(harness); const args = { query: "lithium monitoring", ownerId }; - await cache.setCachedSearch(args, [], baseTelemetry({ query_class: "medication_dose_risk" }), [], { indexingVersionAtRetrievalStart: indexingVersion("request-start"), }); await Promise.resolve(); - - expect(harness.documentReads).toBe(1); + expect(harness.revisionReads).toBe(1); expect(harness.inserts).toHaveLength(0); vi.resetModules(); const matchingHarness = createCacheHarness(); - matchingHarness.expireVersionBeforeSharedWrite(); + matchingHarness.advanceClockBeforeSharedWrite(); const matchingCache = await loadCache(matchingHarness); await matchingCache.setCachedSearch(args, [], baseTelemetry({ query_class: "medication_dose_risk" }), [], { indexingVersionAtRetrievalStart: indexingVersion("request-start"), }); await vi.waitFor(() => expect(matchingHarness.inserts).toHaveLength(1)); - - expect(matchingHarness.documentReads).toBe(1); + expect(matchingHarness.revisionReads).toBe(1); expect(matchingHarness.inserts[0]?.indexing_version).toBe(indexingVersion("request-start")); }); }); @@ -509,7 +556,6 @@ function createOrchestrationHarness() { onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, ): PromiseLike { if (this.table === "documents") { - events.push({ kind: "index_version", value: documentStamp }); return Promise.resolve({ data: [{ id: "doc-1", updated_at: documentStamp, metadata: {} }], error: null, @@ -521,7 +567,17 @@ function createOrchestrationHarness() { return { createAdminClient: () => ({ - rpc: vi.fn(async () => ({ data: [], error: null })), + rpc: vi.fn((name: string) => { + let data: string | unknown[] = []; + if (name === "rag_cache_scope_revision_v1") { + events.push({ kind: "index_version", value: documentStamp }); + data = mockRagScopeRevision([ + { id: "doc-1", metadata: { indexed_at: "unchanged-index", governance: documentStamp } }, + ]); + } + const query = Promise.resolve({ data, error: null }); + return Object.assign(query, { abortSignal: () => query }); + }), from: (table: string) => Object.assign(new Query(table), { insert: async (row: Record) => { @@ -547,7 +603,7 @@ function createOrchestrationHarness() { } describe("answer-to-search cache request context", () => { - it("reuses one request-start indexing version across real answer and nested search cache reads", async () => { + it("shares request-start identity while refreshing every real answer and nested-search read fence", async () => { vi.doUnmock("@/lib/env"); vi.doUnmock("@/lib/deep-memory"); vi.doUnmock("@/lib/clinical-search"); @@ -567,14 +623,33 @@ describe("answer-to-search cache request context", () => { const rag = await import("../src/lib/rag/rag"); const cache = await import("../src/lib/rag/rag-cache"); - const args = { query: "coffee machine policy", ownerId, logQuery: false }; + const { withRagProgrammeRollout } = await import("../src/lib/rag/rag-rollout"); + const { analyzeClinicalQuery } = await import("../src/lib/clinical-search"); + const { buildRetrievalQueryVariants } = await import("../src/lib/rag/rag-retrieval-variants"); + const args = withRagProgrammeRollout({ query: "coffee machine policy", ownerId, logQuery: false }); // Prime stale process-local answer and search entries. The orchestration request below - // must reject both local entries, continue through both shared reads, and carry the one - // request-start version into the nested search instead of resolving it again. - await rag.searchChunksWithTelemetry(args); - await cache.setCachedAnswer(args, {} as RagAnswer); - await vi.waitFor(() => expect(harness.events.some((event) => event.kind === "shared_write")).toBe(true)); + // must reject both local entries and continue through both shared reads. Request-start + // identity is shared, while each hit/lookup and in-flight admission reads its own fresh fence. + const searchArgs = { ...args, topK: 12, minSimilarity: 0.12 }; + const prime = await rag.searchChunksWithTelemetry(searchArgs); + // Empty source-only retrieval does not populate the search cache. Seed its exact + // production identity explicitly and prove the actual consumer can hit it first. + const variants = buildRetrievalQueryVariants(args.query, analyzeClinicalQuery(args.query)); + await cache.setCachedSearch(searchArgs, prime.results, prime.telemetry, variants); + const primed = await rag.searchChunksWithTelemetry(searchArgs); + expect(primed.telemetry.search_cache_hit).toBe(true); + await cache.setCachedAnswer(args, { + answer: "Stale fixture", + grounded: false, + confidence: "unsupported", + citations: [], + sources: [], + } satisfies RagAnswer); + expect((await cache.getCachedAnswer(args, Date.now()))?.answer).toBe("Stale fixture"); + await vi.waitFor(() => + expect(harness.events.some((event) => event.kind === "shared_write" && event.cacheKind === "search")).toBe(true), + ); harness.setDocumentStamp("request-start"); harness.clearEvents(); @@ -600,7 +675,10 @@ describe("answer-to-search cache request context", () => { expect(answer.routingReason).not.toContain("cache_hit"); expect(sharedReads.map((event) => event.indexingVersion)).toEqual([requestVersion, requestVersion]); - expect(requestVersion).toMatch(/:doc-1:request-start:$/); - expect(versionsResolvedBeforeNestedSearch).toEqual([{ kind: "index_version", value: "request-start" }]); + expect(requestVersion).toMatch(/:rag-cache-scope-v1:[0-9a-f]{32}$/); + // In-flight admission, request start, local-answer, shared-answer, local-search, shared-search. + expect(versionsResolvedBeforeNestedSearch).toEqual( + Array.from({ length: 6 }, () => ({ kind: "index_version", value: "request-start" })), + ); }); }); diff --git a/tests/railway-config.test.ts b/tests/railway-config.test.ts index 04eb5766bb..094402736a 100644 --- a/tests/railway-config.test.ts +++ b/tests/railway-config.test.ts @@ -178,6 +178,8 @@ describe("Railway config as code", () => { "worker/python/requirements.txt", // B4: the worker image builds its docling venv from the Gate B lab lock. "eval/docling/requirements.txt", + "eval/docling/model-artifacts.json", + "eval/docling/verify-model-artifacts.mjs", "scripts/build-worker.mjs", "scripts/enable-server-only-stub.mjs", "scripts/register-server-only.mjs", diff --git a/tests/retrieval-access-scope.test.ts b/tests/retrieval-access-scope.test.ts index b28f5a2bb8..91c4d84ee2 100644 --- a/tests/retrieval-access-scope.test.ts +++ b/tests/retrieval-access-scope.test.ts @@ -1,7 +1,8 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mockRagScopeRevision } from "./rag-cache-test-revision"; const root = process.cwd(); const optimizedRetrievalMigration = readFileSync( @@ -26,6 +27,11 @@ function between(source: string, start: string, end: string): string { return source.slice(startIndex, endIndex); } +afterEach(() => { + vi.doUnmock("@/lib/supabase/admin"); + vi.resetModules(); +}); + describe("owner-plus-public retrieval contract", () => { it("adds a separate public-only v3 candidate contract without changing v2", () => { expect(governedV3Migration).toContain("match_document_chunks_text_v3"); @@ -41,7 +47,81 @@ describe("owner-plus-public retrieval contract", () => { const cache = readFileSync(join(root, "src/lib/rag/rag-cache.ts"), "utf8"); expect(contracts).toContain("accessScope?: RetrievalAccessScope"); expect(cache).toContain("retrievalAccessScopeKey(retrievalAccessScopeForArgs(args))"); - expect(cache).toContain("owner_id.is.null"); + expect(cache).toContain('rpc("rag_cache_scope_revision_v1"'); + expect(cache).toContain("p_owner_id: accessScope.ownerId ?? null"); + expect(cache).toContain("p_include_public: accessScope.includePublic"); + const revisionSql = between( + canonicalSchema, + "create function public.rag_cache_scope_revision_v1(", + "-- HY05:", + ).replace(/\s+/g, " "); + expect(revisionSql).toContain("d.owner_id = p_owner_id"); + expect(revisionSql).toContain("coalesce(p_include_public,false) and d.owner_id is null"); + expect(revisionSql).toContain("d.id = any(p_document_ids)"); + expect(revisionSql).toMatch( + /revoke all on function public\.rag_cache_scope_revision_v1\([^]*from public, anon, authenticated;/, + ); + expect(revisionSql).toMatch(/grant execute on function public\.rag_cache_scope_revision_v1\([^]*to service_role;/); + }); + + it("binds fresh revision reads to exact owner/public and document scopes without foreign-owner fallback", async () => { + const documents = [ + { id: "owned", owner_id: "owner-a", metadata: { indexed_at: "unchanged", document_status: "current" } }, + { id: "public", owner_id: null, metadata: { indexed_at: "unchanged", document_status: "current" } }, + { id: "foreign", owner_id: "owner-b", metadata: { indexed_at: "unchanged", document_status: "current" } }, + ]; + const rpc = vi.fn( + async ( + name: string, + params: { p_owner_id: string | null; p_include_public: boolean; p_document_ids: string[] | null }, + ) => { + expect(name).toBe("rag_cache_scope_revision_v1"); + const scoped = documents.filter( + (row) => + ((params.p_owner_id !== null && row.owner_id === params.p_owner_id) || + (row.owner_id === null && params.p_include_public)) && + (!params.p_document_ids?.length || params.p_document_ids.includes(row.id)), + ); + return { data: mockRagScopeRevision(scoped), error: null }; + }, + ); + vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => ({ rpc }) })); + const { cacheIndexingVersion } = await import("../src/lib/rag/rag-cache"); + const version = async ( + accessScope: { ownerId?: string; includePublic: boolean }, + filters: { documentId?: string; documentIds?: string[] } = {}, + ) => cacheIndexingVersion({ ownerId: accessScope.ownerId, accessScope, ...filters }); + const publicOnly = await version({ includePublic: true }); + expect(publicOnly).toContain(mockRagScopeRevision([documents[1]])); + const ownOnly = await version({ ownerId: "owner-a", includePublic: false }); + expect(ownOnly).toContain(mockRagScopeRevision([documents[0]])); + const combined = await version( + { ownerId: "owner-a", includePublic: true }, + { documentIds: ["public", "owned", "public"] }, + ); + expect(combined).toContain(mockRagScopeRevision(documents.slice(0, 2))); + expect(rpc).toHaveBeenLastCalledWith("rag_cache_scope_revision_v1", { + p_owner_id: "owner-a", + p_include_public: true, + p_document_ids: ["owned", "public"], + }); + expect(await version({ ownerId: "owner-a", includePublic: true }, { documentId: "foreign" })).toContain( + mockRagScopeRevision([]), + ); + expect(rpc).toHaveBeenLastCalledWith("rag_cache_scope_revision_v1", { + p_owner_id: "owner-a", + p_include_public: true, + p_document_ids: ["foreign"], + }); + expect(await version({ includePublic: false })).toContain(mockRagScopeRevision([])); + documents[1]!.metadata.document_status = "outdated"; + expect(await version({ includePublic: true })).not.toBe(publicOnly); + expect(await version({ ownerId: "owner-a", includePublic: false })).toBe(ownOnly); + const beforeMismatch = rpc.mock.calls.length; + expect( + await cacheIndexingVersion({ ownerId: "owner-a", accessScope: { ownerId: "owner-b", includePublic: true } }), + ).toContain("index-stamp-unavailable"); + expect(rpc).toHaveBeenCalledTimes(beforeMismatch); }); it("keeps answer cache namespaces distinct across public and owner-plus-public scopes", async () => { diff --git a/tests/roster-auth-transition.dom.test.tsx b/tests/roster-auth-transition.dom.test.tsx new file mode 100644 index 0000000000..f6088a5ec0 --- /dev/null +++ b/tests/roster-auth-transition.dom.test.tsx @@ -0,0 +1,257 @@ +/** @vitest-environment jsdom */ +// Mounted existing-B/examples-off regression. Every request is intercepted; no backend IDOR claim. +import { act, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + auth: { + status: "authenticated", + authEpoch: 1, + session: { user: { id: "synthetic-A", created_at: "2020-01-01T00:00:00Z" } }, + }, + browserActor: "synthetic-A", +})); +vi.mock("@/lib/supabase/client", () => ({ + useAuthSession: () => state.auth, + hasSupabaseAuthSession: () => state.auth.status === "authenticated", +})); +// Explicit example-data off: supported production setting, keeps RosterSampleGate's real child mounted. +vi.mock("@/lib/example-data/store", () => ({ + useExampleData: () => ({ active: false, mode: "off" }), + useAuthIfAvailable: () => state.auth, + areaDataState: () => "has-data", + reportAreaData: () => undefined, +})); +vi.mock("next/navigation", () => ({ + usePathname: () => "/roster/settings", + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), prefetch: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})); +import { useRosterSettings } from "@/components/roster/use-roster-settings"; +import { useRosterShifts } from "@/components/roster/use-roster-shifts"; +import { RosterSettingsPage } from "@/components/roster/roster-settings-page"; +import { RosterSampleGate } from "@/components/roster/roster-sample-gate"; +import { resetSharedGets } from "@/lib/shared-get"; +import { ACCOUNT_TRANSITION_EVENT } from "@/lib/account-scoped-browser-state"; + +function changeToB() { + // Replace the entire published value, as AuthProvider does; never mutate an A closure. + state.auth = { + status: "authenticated", + authEpoch: state.auth.authEpoch + 1, + session: { user: { id: "synthetic-B", created_at: "2020-01-01T00:00:00Z" } }, + }; + state.browserActor = "synthetic-B"; + resetSharedGets(); + window.dispatchEvent(new Event(ACCOUNT_TRANSITION_EVENT)); +} +const syntheticShift = { + id: "synthetic-shift-A", + startsAt: "2026-10-20T00:00:00Z", + endsAt: "2026-10-20T08:00:00Z", + title: "Synthetic A shift", + location: null, + sourceUid: null, + kind: "day", + source: "import", + seriesId: null, + workplace: "Synthetic A workplace", +}; +let calls: { actorAtRequest: string; url: string; method: string }[]; +beforeEach(() => { + state.auth = { + status: "authenticated", + authEpoch: 1, + session: { user: { id: "synthetic-A", created_at: "2020-01-01T00:00:00Z" } }, + }; + state.browserActor = "synthetic-A"; + resetSharedGets(); + calls = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input), + method = init?.method ?? "GET"; + // Models browser ambient-cookie selection at REQUEST time, not a backend authorization bypass. + calls.push({ actorAtRequest: state.browserActor, url, method }); + if (url === "/api/roster/settings") + return Response.json({ + settings: { + calendarShifts: false, + rowName: state.browserActor === "synthetic-A" ? "Synthetic A row" : "Synthetic B row", + codes: {}, + }, + }); + if (url === "/api/roster/shifts") + return Response.json({ + shifts: method === "GET" && state.browserActor === "synthetic-A" ? [syntheticShift] : [], + latestImport: null, + }); + if (url === "/api/roster/team") return Response.json({ teams: [], actorId: state.browserActor }); + if (url === "/api/roster/links") return Response.json({ links: [] }); + return Response.json({}); + }), + ); +}); +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + resetSharedGets(); +}); +describe("audit: Roster respects a mounted account transition", () => { + it("discards A settings and reads B settings after transition", async () => { + const view = renderHook(() => useRosterSettings()); + await waitFor(() => expect(view.result.current.settings.rowName).toBe("Synthetic A row")); + act(() => { + changeToB(); + view.rerender(); + }); + await waitFor(() => expect(view.result.current.settings.rowName).toBe("Synthetic B row")); + }); + it("discards A own shifts after transition", async () => { + const view = renderHook(() => useRosterShifts()); + await waitFor(() => expect(view.result.current.status).toBe("ready")); + expect(view.result.current.shifts[0]?.id).toBe("synthetic-shift-A"); + act(() => { + changeToB(); + view.rerender(); + }); + await waitFor(() => expect(view.result.current.shifts).toHaveLength(0)); + }); + it("cancels A pending delete rather than issuing it under B ambient identity", async () => { + const tree = () => ( + + + + ); + const view = render(tree()); + await screen.findByText("Synthetic A workplace"); + vi.useFakeTimers(); + fireEvent.click(screen.getByRole("button", { name: /Delete my roster data/ })); + expect(screen.getByRole("button", { name: "Undo" })).toBeInTheDocument(); + act(() => { + changeToB(); + view.rerender(tree()); + }); + await act(async () => { + vi.advanceTimersByTime(31_000); + }); + expect(calls.filter((call) => call.url === "/api/roster/shifts" && call.method === "DELETE")).toEqual([]); + }); +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +describe("Roster identity races and same-account controls", () => { + it("drops a late settings GET body after B has loaded", async () => { + const original = globalThis.fetch; + const body = deferred(); + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) === "/api/roster/settings" && state.browserActor === "synthetic-A") + return Promise.resolve({ ok: true, status: 200, json: () => body.promise }); + return original(input, init); + }), + ); + const view = renderHook(useRosterSettings); + await act(async () => {}); + act(() => { + changeToB(); + view.rerender(); + }); + await waitFor(() => expect(view.result.current.settings.rowName).toBe("Synthetic B row")); + await act(async () => body.resolve({ rowName: "Late A row" })); + expect(view.result.current.settings.rowName).toBe("Synthetic B row"); + }); + + for (const status of [200, 500]) { + it(`drops A save completion/rollback (${status}) and A queued settings write`, async () => { + const original = globalThis.fetch; + const pending = deferred(); + const puts: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) === "/api/roster/settings" && init?.method === "PUT") { + puts.push(state.browserActor); + return puts.length === 1 ? pending.promise : Promise.resolve(Response.json({ rowName: "B saved" })); + } + return original(input, init); + }), + ); + const view = renderHook(useRosterSettings); + await waitFor(() => expect(view.result.current.status).toBe("ready")); + let old!: Promise[]; + act(() => { + old = [view.result.current.update({ rowName: "A first" }), view.result.current.update({ rowName: "A second" })]; + }); + await waitFor(() => expect(puts).toHaveLength(1)); + act(() => { + changeToB(); + view.rerender(); + }); + await waitFor(() => expect(view.result.current.settings.rowName).toBe("Synthetic B row")); + await act(async () => { + await view.result.current.update({ rowName: "B saved" }); + }); + await act(async () => { + pending.resolve(Response.json({ rowName: "A first" }, { status })); + await Promise.all(old); + }); + expect(puts).toEqual(["synthetic-A", "synthetic-B"]); + expect(view.result.current.settings.rowName).toBe("B saved"); + }); + } + + it("drops a late own-shifts save and refuses a retained A destructive callback under B", async () => { + const original = globalThis.fetch; + const pending = deferred(); + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL, init?: RequestInit) => + String(input) === "/api/roster/shifts" && init?.method === "POST" ? pending.promise : original(input, init), + ), + ); + const view = renderHook(useRosterShifts); + await waitFor(() => expect(view.result.current.status).toBe("ready")); + const old = view.result.current; + let save!: Promise; + act(() => { + save = old.save({} as Parameters[0]); + }); + act(() => { + changeToB(); + view.rerender(); + }); + await waitFor(() => expect(view.result.current.status).toBe("ready")); + await act(async () => { + pending.resolve(Response.json({ shifts: [syntheticShift] })); + await save; + await old.deleteAll(); + }); + expect(view.result.current.shifts).toEqual([]); + expect(calls.filter((call) => call.method === "DELETE")).toEqual([]); + }); + + it("keeps same-account Undo effective", async () => { + render( + + + , + ); + await screen.findByText("Synthetic A workplace"); + vi.useFakeTimers(); + fireEvent.click(screen.getByRole("button", { name: /Delete my roster data/ })); + fireEvent.click(screen.getByRole("button", { name: "Undo" })); + await act(async () => { + vi.advanceTimersByTime(31_000); + }); + expect(calls.filter((call) => call.method === "DELETE")).toEqual([]); + }); +}); diff --git a/tests/roster-cover-tab.dom.test.tsx b/tests/roster-cover-tab.dom.test.tsx index fdbb8af749..4d1d380507 100644 --- a/tests/roster-cover-tab.dom.test.tsx +++ b/tests/roster-cover-tab.dom.test.tsx @@ -1,13 +1,71 @@ /** @vitest-environment jsdom */ -import { cleanup, render, screen, within } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { afterEach, expect, it, vi } from "vitest"; -import { RosterCoverTab } from "@/components/roster/manage/roster-cover-tab"; +import { RosterCoverGap, RosterCoverTab } from "@/components/roster/manage/roster-cover-tab"; import type { RosterOverview } from "@/lib/roster/team/model"; +const work = vi.hoisted(() => ({ zone: "Australia/Perth" })); +vi.mock("@/components/work-time/use-work-time-zone", () => ({ useWorkTimeZone: () => ({ zone: work.zone }) })); afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); + work.zone = "Australia/Perth"; +}); + +it("posts an overnight Cover gap using the selected Sydney wall clock", async () => { + work.zone = "Australia/Sydney"; + const fetcher = vi.fn(async () => Response.json({ result: { openShiftId: "synthetic" } })); + vi.stubGlobal("fetch", fetcher); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: /Gap:/ })); + fireEvent.change(screen.getByLabelText("Starts (Sydney and Canberra)"), { target: { value: "23:00" } }); + fireEvent.change(screen.getByLabelText("Ends (Sydney and Canberra)"), { target: { value: "07:00" } }); + fireEvent.change(screen.getByLabelText("Shift code"), { target: { value: "N" } }); + fireEvent.click(screen.getByRole("button", { name: "Post gap" })); + await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); + const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string); + expect(body).toMatchObject({ startsAt: "2026-10-17T12:00:00.000Z", endsAt: "2026-10-17T20:00:00.000Z" }); +}); + +it("blocks a Cover gap at a skipped daylight-saving time", () => { + work.zone = "Australia/Sydney"; + const fetcher = vi.fn(); + vi.stubGlobal("fetch", fetcher); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: /Gap:/ })); + fireEvent.change(screen.getByLabelText("Starts (Sydney and Canberra)"), { target: { value: "02:30" } }); + fireEvent.change(screen.getByLabelText("Ends (Sydney and Canberra)"), { target: { value: "08:00" } }); + fireEvent.change(screen.getByLabelText("Shift code"), { target: { value: "D" } }); + expect(screen.getByRole("alert")).toHaveTextContent("Choose valid times"); + expect(screen.getByRole("button", { name: "Post gap" })).toBeDisabled(); + expect(fetcher).not.toHaveBeenCalled(); }); const overview: RosterOverview = { diff --git a/tests/roster-month-page.dom.test.tsx b/tests/roster-month-page.dom.test.tsx index 2b0a7147e3..2da7443651 100644 --- a/tests/roster-month-page.dom.test.tsx +++ b/tests/roster-month-page.dom.test.tsx @@ -20,6 +20,8 @@ const state = vi.hoisted(() => ({ reads: {} as Record, readCalls: [] as [string | null, string][], entries: [] as unknown[], + sources: {} as Record, + retryCalendar: vi.fn(), })); vi.mock("next/navigation", () => ({ @@ -41,7 +43,7 @@ vi.mock("@/components/roster/use-roster-team", () => ({ // The Open shifts row reads every team's list; its own tests cover it. vi.mock("@/components/open-shifts/open-shifts-fit-line", () => ({ OpenShiftsFitLine: () => null })); vi.mock("@/components/work-calendar/use-work-calendar-entries", () => ({ - useWorkCalendarEntries: () => ({ entries: state.entries }), + useWorkCalendarEntries: () => ({ entries: state.entries, sources: state.sources, retry: state.retryCalendar }), })); vi.mock("@/components/roster/use-roster-links", () => ({ useRosterLinks: () => ({ links: [], add: vi.fn() }) })); vi.mock("@/components/roster/use-roster-settings", () => ({ useRosterSettings: () => ({}) })); @@ -91,6 +93,8 @@ function ready(shifts: unknown[], extra: Record = {}) { beforeEach(() => { state.entries = []; + state.sources = {}; + state.retryCalendar.mockClear(); state.readCalls = []; state.reads = {}; state.teams = { @@ -112,6 +116,31 @@ beforeEach(() => { afterEach(cleanup); describe("Roster Month tab", () => { + it.each([ + ["rotations", "Rotations"], + ["bookings", "Course bookings"], + ])("names failed %s, keeps shifts visible and retries only that source", (id, label) => { + state.sources = { [id]: { status: "error", entries: [] } }; + state.entries = [ + { id: "successful", kind: "course", title: "Loaded course", start: "2026-10-07", end: "2026-10-07" }, + ]; + const { rerender } = render(); + const warning = screen.getByTestId(`roster-month-source-${id}`); + expect(warning).toHaveTextContent(`${label} could not be loaded`); + expect(screen.getByTestId("roster-month-lead")).toHaveTextContent("On call tonight"); + expect(screen.getByTestId("roster-month-coming")).toHaveTextContent("Loaded course"); + fireEvent.click(within(warning).getByRole("button", { name: `Retry ${label.toLowerCase()}` })); + expect(state.retryCalendar).toHaveBeenCalledWith(id); + state.sources = { [id]: { status: "ready", entries: [] } }; + state.entries = [ + ...state.entries, + { id: "recovered", kind: "rotation", title: "Recovered rotation", start: "2026-10-09", end: "2026-10-10" }, + ]; + rerender(); + expect(screen.queryByTestId(`roster-month-source-${id}`)).toBeNull(); + expect(screen.getByRole("button", { name: /Fri 9 Oct:.*Recovered rotation/ })).toBeInTheDocument(); + }); + it("draws the month with a spoken shift code on every day and marks today", () => { render(); const grid = screen.getByRole("grid", { name: "Your shifts, October 2026" }); diff --git a/tests/test-runner-safety.test.ts b/tests/test-runner-safety.test.ts index 013d87c929..6d1c0d9da7 100644 --- a/tests/test-runner-safety.test.ts +++ b/tests/test-runner-safety.test.ts @@ -183,6 +183,19 @@ describe("temporary path cleanup", () => { }); describe("repository-wide heavyweight lock", () => { + it.each([ + ["State:\tZ (zombie)\nThreads:\t1\n", true], + ["State:\tX (dead)\nThreads:\t1\n", true], + ["State:\tZ (zombie)\nThreads:\t2\n", false], + ["State:\tZ (zombie)\n", false], + ["State:\tZ (zombie)\nThreads:\tunknown\n", false], + ["State:\tZ (zombie)\nThreads:\t1 extra\n", false], + ["State:\tZ (zombie)\nThreads:\t0\n", false], + ["State:\tS (sleeping)\nThreads:\t1\n", false], + ])("reclaims only an explicitly single-thread defunct Linux status %j", (status, expected) => { + expect(testRunLockInternals.linuxProcessIsDefunct(status)).toBe(expected); + }); + it("lets exclusive work queue through long browser or build leases without making focused runs wait indefinitely", () => { expect(testRunLockInternals.defaultWaitTimeoutFor("shared")).toBe(30_000); expect(testRunLockInternals.defaultWaitTimeoutFor("exclusive")).toBe(15 * 60_000); @@ -676,6 +689,168 @@ describe("repository-wide heavyweight lock", () => { replacement.release(); }); + it.runIf(process.platform === "linux")( + "reclaims an actual zombie owner's lease while preserving a live owner", + async () => { + const baseDirectory = temporaryDirectory("clinical-kb-zombie-lock-"); + const repositoryIdentity = path.join(baseDirectory, "shared.git"); + // Node reaps its own children automatically. A tiny Python parent keeps its + // exited fork unreaped until stdin closes, then always waitpids it in finally. + const fixture = spawn( + process.env.PYTHON_BIN || "python3", + [ + "-u", + "-c", + ` +import os, sys +reader, writer = os.pipe() +pid = os.fork() +if pid == 0: + os.close(writer) + os.read(reader, 1) + os._exit(0) +os.close(reader) +print(pid, flush=True) +try: + sys.stdin.readline() + os.write(writer, b"x") + os.close(writer) + sys.stdin.readline() +finally: + os.waitpid(pid, 0) +`, + ], + { stdio: ["pipe", "pipe", "pipe"] }, + ); + let stdout = ""; + let stderr = ""; + let launchError: Error | undefined; + fixture.stdout.on("data", (chunk) => (stdout += String(chunk))); + fixture.stderr.on("data", (chunk) => (stderr += String(chunk))); + fixture.on("error", (error) => { + launchError = error; + }); + const finished = new Promise((resolve) => fixture.on("close", resolve)); + let first: ReturnType | undefined; + let replacement: ReturnType | undefined; + try { + await waitForCondition(() => { + if (launchError) throw launchError; + return /^\d+\n/.test(stdout); + }); + const pid = Number(stdout.trim()); + expect(testRunLockInternals.processIsAlive(pid)).toBe(true); + const acquire = (processId: number, worktree: string) => + acquireHeavyRunLock({ + projectRoot: path.join(baseDirectory, worktree), + repositoryIdentity, + baseDirectory, + environment: {}, + processId, + command: "zombie liveness fixture", + waitTimeoutMs: 0, + }); + first = acquire(pid, "worktree-a"); + expect(() => acquire(process.pid, "worktree-b")).toThrow(/heavyweight command is active/); + + fixture.stdin.write("exit-child\n"); + await waitForCondition(() => /^State:\s+Z\b/m.test(readFileSync(`/proc/${pid}/status`, "utf8"))); + expect(() => process.kill(pid, 0)).not.toThrow(); + // The public API must prune the defunct lease immediately, without force + // or an aged heartbeat; kill(pid, 0) alone incorrectly blocks this call. + replacement = acquire(process.pid, "worktree-b"); + expect(testRunLockInternals.processIsAlive(pid)).toBe(false); + expect(testRunLockInternals.processIsAlive(process.pid)).toBe(true); + first.release(); + expect(readFileSync(path.join(requireLeasePath(replacement), "owner.json"), "utf8")).toContain( + replacement.owner.token, + ); + } finally { + replacement?.release(); + first?.release(); + fixture.stdin.end("finish\nfinish\n"); + expect(await finished, stderr).toBe(0); + } + }, + ); + + it.runIf(process.platform === "linux")( + "preserves a lease when its zombie leader still has a live worker thread", + async () => { + const baseDirectory = temporaryDirectory("clinical-kb-threaded-zombie-lock-"); + const repositoryIdentity = path.join(baseDirectory, "shared.git"); + const fixture = spawn( + process.env.PYTHON_BIN || "python3", + [ + "-u", + "-c", + ` +import ctypes, os, sys, threading +def worker(): + while sys.stdin.readline(): + print("worker-alive", flush=True) +print("ready", flush=True) +sys.stdin.readline() +threading.Thread(target=worker).start() +ctypes.CDLL(None).pthread_exit(None) +`, + ], + { stdio: ["pipe", "pipe", "pipe"] }, + ); + let stdout = ""; + let stderr = ""; + let launchError: Error | undefined; + fixture.stdout.on("data", (chunk) => (stdout += String(chunk))); + fixture.stderr.on("data", (chunk) => (stderr += String(chunk))); + fixture.on("error", (error) => { + launchError = error; + }); + const finished = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => + fixture.on("close", (code, signal) => resolve({ code, signal })), + ); + let first: ReturnType | undefined; + let replacement: ReturnType | undefined; + try { + await waitForCondition(() => { + if (launchError) throw launchError; + return stdout.includes("ready"); + }); + const pid = fixture.pid!; + const acquire = (processId: number, worktree: string) => + acquireHeavyRunLock({ + projectRoot: path.join(baseDirectory, worktree), + repositoryIdentity, + baseDirectory, + environment: {}, + processId, + command: "live threaded leader fixture", + waitTimeoutMs: 0, + }); + first = acquire(pid, "worktree-a"); + fixture.stdin.write("exit-leader\n"); + await waitForCondition(() => { + const status = readFileSync(`/proc/${pid}/status`, "utf8"); + return /^State:\s+Z\b/m.test(status) && /^Threads:\s+2\s*$/m.test(status); + }); + fixture.stdin.write("ping-worker\n"); + await waitForCondition(() => stdout.includes("worker-alive")); + expect(() => process.kill(pid, 0)).not.toThrow(); + // Even though the leader exited, the heartbeat's owner process can still + // execute work. A competing lease must not prune its current token. + expect(() => { + replacement = acquire(process.pid, "worktree-b"); + }).toThrow(/heavyweight command is active/); + expect(testRunLockInternals.processIsAlive(pid)).toBe(true); + expect(readFileSync(path.join(requireLeasePath(first), "owner.json"), "utf8")).toContain(first.owner.token); + } finally { + replacement?.release(); + first?.release(); + fixture.kill("SIGTERM"); + expect(await finished, stderr).toMatchObject({ code: null, signal: "SIGTERM" }); + } + }, + ); + it("allows an explicit force-lock-release to replace a live owner", () => { const baseDirectory = temporaryDirectory("clinical-kb-force-lock-"); const repositoryIdentity = path.join(baseDirectory, "shared.git"); diff --git a/tests/trusted-document-admission.test.ts b/tests/trusted-document-admission.test.ts index d303542918..e2dbc511a7 100644 --- a/tests/trusted-document-admission.test.ts +++ b/tests/trusted-document-admission.test.ts @@ -564,7 +564,7 @@ describe("superseded collision object release", () => { } }); - it("reports an unrecorded object, with both failure reasons, when removal and the ledger fail", async () => { + it("reports an unrecorded object, with both failure stages and safe codes, when removal and the ledger fail", async () => { await expect( release( vi.fn(async () => failed), @@ -572,14 +572,24 @@ describe("superseded collision object release", () => { ), ).resolves.toEqual({ outcome: "unrecorded", - failures: ["remove: storage unavailable", "queue: storage unavailable"], + failures: [ + { stage: "remove", code: "unknown" }, + { stage: "queue", code: "unknown" }, + ], }); - await expect( - release( - vi.fn(async () => failed), - vi.fn(async () => Promise.reject(new Error("db"))), - ), - ).resolves.toEqual({ outcome: "unrecorded", failures: ["remove: storage unavailable", "queue: db"] }); + const rejected = await release( + vi.fn(async () => failed), + vi.fn(async () => Promise.reject(new Error("db"))), + ); + expect(rejected).toEqual({ + outcome: "unrecorded", + failures: [ + { stage: "remove", code: "unknown" }, + { stage: "queue", code: "provider_error" }, + ], + }); + // Only a stage and a safe code are reported; raw provider messages never reach the logs. + expect(JSON.stringify(rejected)).not.toMatch(/storage unavailable|db/); }); it("never touches the path the committed document still uses", async () => { diff --git a/tests/ui-tools-search-mode-mockup.spec.ts b/tests/ui-tools-search-mode-mockup.spec.ts index df61cd58b1..323506ec62 100644 --- a/tests/ui-tools-search-mode-mockup.spec.ts +++ b/tests/ui-tools-search-mode-mockup.spec.ts @@ -114,6 +114,8 @@ test.describe("Perfected Tools results mode mockup @mockup", () => { // A hard-coded total silently rots the moment the catalogue gains a tool, which // is what "Add Ward Flow" (#2140, since retired) did to the 14 this line used to carry. const results = mockup.locator('section[aria-label="Tool results"] li'); + // Shell visibility and input state can precede the results after hydration. + await expect(results.first()).toBeVisible(); const rendered = await results.count(); expect(rendered, "the Compare mockup must render at least one tool").toBeGreaterThan(0); await expect(mockup.getByText(`${rendered} ${rendered === 1 ? "tool" : "tools"}`, { exact: true })).toBeVisible(); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 1132988d57..84add76548 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -3603,3 +3603,35 @@ test.describe("Responsive layout guards", () => { expect(appRequests).toEqual([]); }); }); + +test.describe("physiological calculator validation hold", () => { + for (const viewport of [ + { name: "phone", width: 390, height: 844 }, + { name: "desktop", width: 1440, height: 900 }, + ]) { + test(`withholds unvalidated physiological outputs and preserves psychometric tools on ${viewport.name}`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + await blockExternalRequests(page); + await gotoLauncher(page, "/calculators/search"); + + // The whole panel stays off the page until clinician verification, even + // though the retained component independently enforces its release hold. + await expect(page.getByTestId("clinical-math-panel")).toHaveCount(0); + await expect(page.getByTestId("clinical-math-panel-toggle")).toHaveCount(0); + await expect(page.getByTestId("clinical-math-release-hold")).toHaveCount(0); + await expect(page.getByTestId("clinical-math-renal-results")).toHaveCount(0); + await expect(page.getByTestId("clinical-math-qtc-results")).toHaveCount(0); + await expect(page.getByTestId("clinical-math-lithium-level")).toHaveCount(0); + await expect(page.getByTestId("clinical-math-lithium-results")).toHaveCount(0); + await expect(page.getByTestId("clinical-math-lithium-suggested-dose")).toHaveCount(0); + + const psychometricList = page.getByTestId("calculator-result-list"); + await psychometricList.scrollIntoViewIfNeeded(); + await expect(psychometricList.getByRole("button", { name: /^Open PHQ-9/ })).toBeVisible(); + await expect(psychometricList.getByRole("button")).toHaveCount(5); + await expectNoPageHorizontalOverflow(page); + }); + } +}); diff --git a/tests/upload-diagnostic-minimisation.test.ts b/tests/upload-diagnostic-minimisation.test.ts new file mode 100644 index 0000000000..ead5beccd4 --- /dev/null +++ b/tests/upload-diagnostic-minimisation.test.ts @@ -0,0 +1,198 @@ +import { createHash } from "node:crypto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + admin: vi.fn(), + dispatch: vi.fn(), + upload: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); +const ownerId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + +vi.mock("@/lib/env", () => ({ + env: { + MAX_UPLOAD_MB: 150, + MAX_CONCURRENT_UPLOADS: 1, + MAX_IN_FLIGHT_UPLOAD_MB: 151, + SUPABASE_DOCUMENT_BUCKET: "clinical-documents", + SUPABASE_IMAGE_BUCKET: "clinical-images", + WORKER_MAX_ATTEMPTS: 3, + }, +})); +vi.mock("@/lib/logger", () => ({ logger: { info: mocks.info, warn: mocks.warn, error: mocks.error } })); +vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: mocks.admin })); +vi.mock("@/lib/supabase/auth", async (original) => ({ + ...(await original()), + requireAuthenticatedUser: async () => ({ id: ownerId, appMetadata: { site_role: "administrator" } }), +})); +vi.mock("@/lib/supabase/server", () => ({ + createSupabaseServerClient: async () => ({ + auth: { + getClaims: async () => ({ + data: { + claims: { + sub: ownerId, + role: "authenticated", + aal: "aal2", + app_metadata: { site_role: "administrator" }, + amr: [{ method: "totp", timestamp: Math.floor(Date.now() / 1000) - 5 }], + }, + }, + error: null, + }), + }, + }), +})); +vi.mock("@/lib/api-rate-limit", () => ({ consumeSubjectApiRateLimit: async () => ({ limited: false }) })); +vi.mock("@/lib/supabase/health", () => ({ probeSupabaseHealth: async () => ({ ok: true }) })); +vi.mock("@/lib/upload-structure", () => ({ assertUploadStructure: async () => undefined })); +vi.mock("@/lib/document-naming", () => ({ + planDocumentName: async ({ fileName }: { fileName: string }) => ({ + title: "Synthetic document", + baseTitle: "Synthetic document", + originalTitle: null, + originalFileName: fileName, + duplicateIndex: 1, + duplicateGroupKey: "synthetic-group", + duplicateReason: "none", + }), +})); +vi.mock("@/lib/trusted-document-admission", async (original) => ({ + ...(await original()), + dispatchTrustedDocumentAdmission: mocks.dispatch, + uploadTrustedDocumentBytes: mocks.upload, +})); + +import { POST } from "@/app/api/upload/route"; +import { TrustedDocumentAdmissionDispatchError } from "@/lib/trusted-document-admission"; + +// Wholly invented identifiers; deliberately not a pattern-dependent MRN redaction case. +const filename = "Synthetic_Mira_Quinn_DOB_1982-03-04.pdf"; +const providerText = `Provider refused owner/private/${filename}; contact synthetic-person@example.invalid; token synthetic-private-value`; +const authority = { + version: 1, + sourceRole: "clinical_guideline", + authorityBasis: "administrator_template", + licencePolicy: "review_required", + rightsEvidenceRef: "synthetic-rights-evidence", + publisher: "Synthetic Publisher", + jurisdiction: "Australia", + publicationDate: null, + effectiveDate: null, + reviewDate: null, + expiryDate: null, + currentness: "current", + supersedesDocumentId: null, + purpose: "clinical_retrieval", + supportedFeatures: ["text"], + providerProcessing: "permitted", +}; + +function request() { + const form = new FormData(); + form.set("file", new File(["%PDF-1.7\n%%EOF"], filename, { type: "application/pdf" })); + form.set("authority", JSON.stringify(authority)); + return new Request("http://localhost/api/upload", { + method: "POST", + body: form, + headers: { "Idempotency-Key": "11111111-1111-4111-8111-111111111111" }, + }); +} + +function client({ ledgerFails = false, documentFails = false } = {}) { + const remove = vi.fn(async () => ({ error: { message: providerText } })); + const insert = vi.fn(async () => ({ error: ledgerFails ? { message: providerText } : null })); + const maybeSingle = vi.fn(async () => ({ data: null, error: documentFails ? { message: providerText } : null })); + const query = { select: () => query, eq: () => query, maybeSingle }; + const from = vi.fn((table: string) => { + if (table === "storage_cleanup_jobs") return { insert }; + if (table === "documents") return query; + throw new Error("Unexpected test table"); + }); + return { storage: { from: vi.fn(() => ({ remove })) }, from, remove, insert, maybeSingle }; +} + +async function assertSafeFailure(operation: Promise) { + const settled = await operation.then( + (response) => ({ response, rejected: null }), + (rejected: unknown) => ({ response: null, rejected }), + ); + const body = settled.response ? await settled.response.text() : ""; + const diagnostics = JSON.stringify({ + body, + rejected: settled.rejected instanceof Error ? settled.rejected.message : settled.rejected, + logs: [...mocks.info.mock.calls, ...mocks.warn.mock.calls, ...mocks.error.mock.calls], + }); + for (const privateText of [ + filename, + "Mira", + "1982-03-04", + "synthetic-person@example.invalid", + "synthetic-private-value", + "Provider refused", + ]) { + expect(diagnostics).not.toContain(privateText); + } + expect(settled.rejected).toBeNull(); + expect(settled.response?.status).toBe(500); + expect(JSON.parse(body)).toMatchObject({ error: "Request failed.", code: "internal_error" }); + expect(JSON.parse(body)).not.toHaveProperty("duplicate"); +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.dispatch.mockImplementation(async ({ replayOnly }: { replayOnly: boolean }) => { + if (replayOnly) return { outcome: "not_found" }; + throw new TrustedDocumentAdmissionDispatchError("rejected", "duplicate_content"); + }); + mocks.upload.mockImplementation(async ({ intendedStoragePath }: { intendedStoragePath: string }) => ({ + storagePath: intendedStoragePath, + collisionRecovered: false, + })); +}); + +describe("upload diagnostics omit filenames and provider error content", () => { + it("fails closed with a safe response when duplicate storage removal and the exact-path cleanup ledger both fail", async () => { + const supabase = client({ ledgerFails: true }); + mocks.admin.mockReturnValue(supabase); + + await assertSafeFailure(POST(request())); + + expect(mocks.dispatch).toHaveBeenCalledTimes(2); + expect(mocks.upload).toHaveBeenCalledTimes(1); + const path = mocks.upload.mock.calls[0]![0].intendedStoragePath as string; + expect(path).toContain(filename); + expect(supabase.remove).toHaveBeenCalledExactlyOnceWith([path]); + expect(supabase.insert).toHaveBeenCalledExactlyOnceWith({ + document_bucket: "clinical-documents", + document_paths: [path], + owner_id: ownerId, + status: "pending", + image_bucket: "clinical-images", + image_paths: [], + }); + // Diagnostics retain an opaque correlation reference, while only the scoped recovery row gets the exact path. + expect(mocks.warn).toHaveBeenCalledWith("Duplicate upload storage cleanup failed", { + storageObjectRef: createHash("sha256").update(path).digest("hex"), + errorType: "UnknownError", + }); + expect(mocks.error).toHaveBeenCalledTimes(1); + expect(mocks.error.mock.calls[0]![1]).toEqual({ errorType: "Error" }); + }); + + it("logs only the content-free error envelope for an ordinary provider failure", async () => { + const supabase = client({ documentFails: true }); + mocks.admin.mockReturnValue(supabase); + + await assertSafeFailure(POST(request())); + + expect(mocks.upload).not.toHaveBeenCalled(); + expect(mocks.dispatch).not.toHaveBeenCalled(); + expect(mocks.warn).not.toHaveBeenCalled(); + expect(mocks.error).toHaveBeenCalledTimes(1); + expect(mocks.error.mock.calls[0]![0]).toEqual(expect.any(String)); + expect(mocks.error.mock.calls[0]![1]).toEqual({ errorType: "Error" }); + }); +}); diff --git a/tests/work-calendar-entries-enabled.dom.test.tsx b/tests/work-calendar-entries-enabled.dom.test.tsx index 717f6d0eda..3addf6d8d4 100644 --- a/tests/work-calendar-entries-enabled.dom.test.tsx +++ b/tests/work-calendar-entries-enabled.dom.test.tsx @@ -7,6 +7,7 @@ import { renderHook } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; const reads = vi.hoisted(() => ({ rotations: vi.fn(), bookings: vi.fn() })); +const failures = vi.hoisted(() => ({ on: false, rotations: vi.fn(), bookings: vi.fn() })); vi.mock("@/components/work-calendar/sources", () => ({ WORK_CALENDAR_SOURCES: [ @@ -14,14 +15,14 @@ vi.mock("@/components/work-calendar/sources", () => ({ id: "rotations", read: (enabled: boolean) => { reads.rotations(enabled); - return { status: enabled ? "ready" : "off", entries: [] }; + return { status: enabled ? (failures.on ? "error" : "ready") : "off", entries: [], retry: failures.rotations }; }, }, { id: "bookings", read: (enabled: boolean) => { reads.bookings(enabled); - return { status: enabled ? "ready" : "off", entries: [] }; + return { status: enabled ? (failures.on ? "error" : "ready") : "off", entries: [], retry: failures.bookings }; }, }, ], @@ -46,4 +47,16 @@ describe("useWorkCalendarEntries enabled", () => { expect(reads.rotations).not.toHaveBeenCalledWith(true); expect(result.current).toMatchObject({ status: "off", entries: [] }); }); + + it("retries only the named failed source and retains retry-all compatibility", () => { + failures.on = true; + const { result } = renderHook(() => useWorkCalendarEntries()); + result.current.retry("bookings"); + expect(failures.bookings).toHaveBeenCalledTimes(1); + expect(failures.rotations).not.toHaveBeenCalled(); + result.current.retry(); + expect(failures.bookings).toHaveBeenCalledTimes(2); + expect(failures.rotations).toHaveBeenCalledTimes(1); + failures.on = false; + }); }); diff --git a/tests/work-roles-hospital-sick-release.test.ts b/tests/work-roles-hospital-sick-release.test.ts new file mode 100644 index 0000000000..4316db92dc --- /dev/null +++ b/tests/work-roles-hospital-sick-release.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + admin: vi.fn(), + auth: vi.fn(), + roles: vi.fn(), + read: vi.fn(), + rate: vi.fn(), + demo: vi.fn(), +})); +vi.mock("@/lib/env", () => ({ isDemoMode: mocks.demo })); +vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: mocks.admin })); +vi.mock("@/lib/supabase/auth", () => ({ + AuthenticationError: class AuthenticationError extends Error {}, + requireAuthenticatedUser: mocks.auth, + unauthorizedResponse: () => Response.json({ error: "Unauthorized" }, { status: 401 }), +})); +vi.mock("@/lib/api-rate-limit", () => ({ + allowRateLimitInMemoryFallbackOnUnavailable: () => false, + consumeSubjectApiRateLimit: mocks.rate, + rateLimitJsonResponse: () => Response.json({ error: "Limited" }, { status: 429 }), +})); +vi.mock("@/lib/work-roles/server", () => ({ loadWorkRoleContext: mocks.roles })); +vi.mock("@/lib/work-roles/hospital-sick", () => ({ readHospitalSickCalls: mocks.read })); + +import { GET } from "@/app/api/work/hospital/sick/route"; +import { PublicApiError } from "@/lib/http"; +import { AuthenticationError } from "@/lib/supabase/auth"; + +const hospitalId = "11111111-1111-4111-8111-111111111111"; +const user = { id: "synthetic-workforce", appMetadata: {} }; +const context = { userId: user.id, ready: true, grants: [{ role: "workforce", hospitalId, serviceIds: ["team"] }] }; +const client = { synthetic: true }; +const request = () => new Request(`https://example.invalid/api/work/hospital/sick?hospitalId=${hospitalId}`); + +beforeEach(() => { + vi.resetAllMocks(); + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("ROSTER_TEAM_RELEASE_ENABLED", ""); + mocks.demo.mockReturnValue(false); + mocks.admin.mockReturnValue(client); + mocks.auth.mockResolvedValue(user); + mocks.roles.mockResolvedValue(context); + mocks.rate.mockResolvedValue({ limited: false }); + mocks.read.mockResolvedValue({ + hospital: { id: hospitalId, name: "Synthetic Hospital" }, + teams: [], + calls: [{ id: "synthetic-report", name: "Dr Synthetic Private Staff", status: "needs-cover" }], + }); +}); +afterEach(() => vi.unstubAllEnvs()); + +describe("hospital sick real-staff release boundary", () => { + it.each(["", "false", "TRUE"])("holds production without reading named staff data (flag %j)", async (flag) => { + vi.stubEnv("ROSTER_TEAM_RELEASE_ENABLED", flag); + const response = await GET(request()); + expect(response.status).toBe(503); + expect(await response.text()).not.toContain("Dr Synthetic Private Staff"); + expect(mocks.admin).not.toHaveBeenCalled(); + expect(mocks.roles).not.toHaveBeenCalled(); + expect(mocks.read).not.toHaveBeenCalled(); + }); + + it("preserves authentication, current role loading and requested hospital when explicitly enabled", async () => { + vi.stubEnv("ROSTER_TEAM_RELEASE_ENABLED", "true"); + const response = await GET(request()); + expect(response.status).toBe(200); + expect(mocks.auth).toHaveBeenCalledWith(expect.any(Request), client); + expect(mocks.roles).toHaveBeenCalledWith(client, user); + expect(mocks.read).toHaveBeenCalledWith(client, context, hospitalId); + expect(response.headers.get("Cache-Control")).toContain("no-store"); + }); + + it("still denies an unauthenticated enabled request", async () => { + vi.stubEnv("ROSTER_TEAM_RELEASE_ENABLED", "true"); + mocks.auth.mockRejectedValue(new AuthenticationError()); + expect((await GET(request())).status).toBe(401); + expect(mocks.read).not.toHaveBeenCalled(); + }); + + it("preserves the reader's hospital-role denial after enablement", async () => { + vi.stubEnv("ROSTER_TEAM_RELEASE_ENABLED", "true"); + mocks.read.mockRejectedValue(new PublicApiError("Role required", 403, { code: "work_role_required" })); + expect((await GET(request())).status).toBe(403); + }); + + it("still rate limits an enabled request before loading staff roles or reports", async () => { + vi.stubEnv("ROSTER_TEAM_RELEASE_ENABLED", "true"); + mocks.rate.mockResolvedValue({ limited: true }); + expect((await GET(request())).status).toBe(429); + expect(mocks.roles).not.toHaveBeenCalled(); + expect(mocks.read).not.toHaveBeenCalled(); + }); + + it("never reads real staff in demo mode even with the release flag", async () => { + vi.stubEnv("ROSTER_TEAM_RELEASE_ENABLED", "true"); + mocks.demo.mockReturnValue(true); + expect((await GET(request())).status).toBe(503); + expect(mocks.admin).not.toHaveBeenCalled(); + expect(mocks.read).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/work-roles-hospital-sick.test.ts b/tests/work-roles-hospital-sick.test.ts index 755d05a0f5..b8c91348df 100644 --- a/tests/work-roles-hospital-sick.test.ts +++ b/tests/work-roles-hospital-sick.test.ts @@ -44,6 +44,23 @@ const shift = { }; describe("hospital sick calls", () => { + it.each([ + { userId: "unprivileged", ready: true, grants: [] }, + { + userId: "wrong-hospital", + ready: true, + grants: [{ role: "workforce" as const, hospitalId: "h2", serviceIds: ["t2"] }], + }, + { userId: "team-manager", ready: true, grants: [{ role: "manager" as const, serviceId: "t1" }] }, + ])("rejects a caller without a current hospital-level role ($userId) before reading staff", async (context) => { + const from = () => { + throw new Error("Denied requests must not query staff tables"); + }; + await expect( + readHospitalSickCalls({ from } as unknown as Parameters[0], context, "h1"), + ).rejects.toMatchObject({ status: 403, details: { code: "work_role_required" } }); + }); + it("lists only reported shifts and names the doctor rostered when it was reported, not the poster or the cover", async () => { const calls: string[] = []; const client = fakeClient( diff --git a/tests/work-roles-people-changes.test.ts b/tests/work-roles-people-changes.test.ts index 9f1c7372df..b661a20518 100644 --- a/tests/work-roles-people-changes.test.ts +++ b/tests/work-roles-people-changes.test.ts @@ -14,6 +14,7 @@ import { loadWorkRoleContext, type WorkRoleContext } from "@/lib/work-roles/serv type Row = Record; type Tables = Record; +type QueryResult = { data: unknown; error: { code: string } | null }; const H1 = "11111111-1111-4111-8111-111111111111"; const H2 = "22222222-2222-4222-8222-222222222222"; @@ -24,6 +25,7 @@ const ADMIN = "99999999-9999-4999-8999-999999999999"; const WORKFORCE = "88888888-8888-4888-8888-888888888888"; function fakeDb(tables: Tables) { + const state = { rpcUnavailable: false }; const writes: { table: string; kind: "update" | "insert"; values: Row }[] = []; function nameTaken(table: string, values: Row, self: Row | null): boolean { @@ -42,7 +44,7 @@ function fakeDb(tables: Tables) { let single = false; let max = Infinity; - function run(): { data: unknown; error: { code: string } | null } { + function run(): QueryResult { const rows = (tables[table] ??= []); if (mode.kind === "insert") { if (nameTaken(table, mode.values, null)) return { data: null, error: { code: "23505" } }; @@ -56,7 +58,25 @@ function fakeDb(tables: Tables) { for (const row of matched) { if (nameTaken(table, mode.values, row)) return { data: null, error: { code: "23505" } }; } - for (const row of matched) Object.assign(row, mode.values); + for (const row of matched) { + const oldHospital = row.hospital_id; + Object.assign(row, mode.values); + // Model the database-owned atomic move/revocation contract. Actual trigger SQL + // failure and concurrency are exercised separately on PostgreSQL. + if (table === "work_hospital_teams" && oldHospital !== row.hospital_id) { + for (const grant of tables.work_role_grants ?? []) { + if ( + grant.role === "supervisor" && + grant.service_id === row.service_id && + grant.subject_user_id == null && + grant.revoked_at == null + ) { + grant.revoked_at = row.linked_at; + grant.revoked_by = row.linked_by; + } + } + } + } writes.push({ table, kind: "update", values: mode.values }); } return { data: single ? (matched[0] ?? null) : matched.map((row) => ({ ...row })), error: null }; @@ -93,7 +113,7 @@ function fakeDb(tables: Tables) { single = true; return query; }, - then: (resolve: (value: unknown) => unknown, reject?: (reason: unknown) => unknown) => + then: (resolve: (value: QueryResult) => unknown, reject?: (reason: unknown) => unknown) => Promise.resolve(run()).then(resolve, reject), }; return query; @@ -116,9 +136,18 @@ function fakeDb(tables: Tables) { }), }, }, - rpc: async () => ({ data: null, error: null }), + rpc: async (name: string, args: Record) => { + if (state.rpcUnavailable) return { data: null, error: { code: "PGRST202", message: "Missing move RPC" } }; + if (name !== "work_move_team_v1") throw new Error("Unexpected RPC"); + const result = await from("work_hospital_teams") + .update({ hospital_id: args.p_to_hospital_id, linked_by: args.p_actor_id, linked_at: new Date().toISOString() }) + .eq("service_id", args.p_service_id) + .eq("hospital_id", args.p_expected_hospital_id) + .select(); + return result.error ? result : { data: args.p_expected_hospital_id, error: null }; + }, }; - return { client: client as unknown as Parameters[0], tables, writes }; + return { client: client as unknown as Parameters[0], tables, writes, state }; } function seed(): Tables { @@ -206,6 +235,18 @@ describe("the change schema", () => { }); describe("move a team to another hospital", () => { + it("refuses before moving when the required transaction RPC is absent", async () => { + const db = fakeDb(seed()); + db.state.rpcUnavailable = true; + const before = structuredClone(db.tables); + expect( + (await refusal(applyPeopleChange(db.client, admin, { action: "move-team", serviceId: TEAM, toHospitalId: H2 }))) + .status, + ).toBe(503); + expect(db.tables).toEqual(before); + expect(db.writes).toEqual([]); + }); + it("moves the link, removes only whole-team supervisors, and answers with the hospital it left", async () => { const db = fakeDb(seed()); // A supervisor of a named trainee in this team stays: scoped to the old hospital, it just stops matching. diff --git a/tests/worker-python-lock.test.ts b/tests/worker-python-lock.test.ts index 51cb2c6df6..bfbcc3cbf4 100644 --- a/tests/worker-python-lock.test.ts +++ b/tests/worker-python-lock.test.ts @@ -2,6 +2,28 @@ import { existsSync, readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; describe("worker Python lockfile", () => { + it("keeps the Cloud Cython pin compatible with quicksectx 0.4.1", () => { + const contents = readFileSync("worker/python/requirements-cloud.txt", "utf8"); + expect(contents).toMatch(/^quicksectx==0\.4\.1\s/m); + const cython = contents.match(/^cython==(\d+)\.(\d+)\.(\d+)\s/m); + expect(cython, "Cloud lock must pin Cython").not.toBeNull(); + // quicksectx 0.4.1 declares Cython>=0.25,<3.2.0 in its package metadata. + const [, major, minor] = cython!; + expect(Number(major) > 0 || Number(minor) >= 25).toBe(true); + expect(Number(major) < 3 || (Number(major) === 3 && Number(minor) < 2)).toBe(true); + }); + + it("keeps the Cloud Thinc pin compatible with spaCy 3.8.16", () => { + const contents = readFileSync("worker/python/requirements-cloud.txt", "utf8"); + expect(contents).toMatch(/^spacy==3\.8\.16\s/m); + const thinc = contents.match(/^thinc==(\d+)\.(\d+)\.(\d+)\s/m); + expect(thinc, "Cloud lock must pin Thinc").not.toBeNull(); + // spaCy 3.8.16 declares thinc>=8.3.12,<8.4.0 in its package metadata. + const [, major, minor, patch] = thinc!; + expect([Number(major), Number(minor)]).toEqual([8, 3]); + expect(Number(patch)).toBeGreaterThanOrEqual(12); + }); + it("has hashed, pinned locks for production Python 3.11 and Cloud Python 3.12", () => { expect(existsSync("worker/python/requirements.in"), "requirements.in missing").toBe(true); for (const [file, version] of [ diff --git a/worker/main.ts b/worker/main.ts index 0ed79af99c..fafed89e39 100644 --- a/worker/main.ts +++ b/worker/main.ts @@ -38,7 +38,7 @@ import { import { shouldPersistJobProgress } from "../src/lib/ingestion"; import { assessDocumentIndexQuality } from "../src/lib/index-quality"; import { classifyAndCaptionImageFromBase64, embedTexts } from "../src/lib/openai"; -import { safeErrorLogDetails, redactCaptionIdentifiers } from "../src/lib/privacy"; +import { contentFreeErrorDetails, safeErrorLogDetails, redactCaptionIdentifiers } from "../src/lib/privacy"; import { isAtomicReindexCandidate } from "../src/lib/reindex-pipeline"; // Deliberate exception to the "ingestion does not import rag.ts" rule in the archived // architecture handover: after a document commits, the worker must drop that owner's cached @@ -2496,22 +2496,28 @@ export async function runWorkerMain() { const abort = error instanceof WorkerAbortError ? error : null; console.error( abort ? "PsychSift worker aborted" : "PsychSift worker stopped unexpectedly", - safeErrorLogDetails(error), + contentFreeErrorDetails(error), ); captureWorkerException(error, "fatal"); + const failureDetails = contentFreeErrorDetails(error); + // WorkerAbortError wraps the underlying failure in `cause`; read its allowlisted code too. + const failureCode = + failureDetails.errorCode ?? + (abort?.cause ? contentFreeErrorDetails(abort.cause).errorCode : undefined) ?? + "unclassified"; if (env.WORKER_FAILURE_WEBHOOK_URL) { try { await fetch(env.WORKER_FAILURE_WEBHOOK_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - text: `CRITICAL: PsychSift worker ${abort ? "aborted" : "stopped unexpectedly"}. Error: ${error instanceof Error ? error.message : String(error)}`, + text: `CRITICAL: PsychSift worker ${abort ? "aborted" : "stopped unexpectedly"}. errorType=${failureDetails.errorType} errorCode=${failureCode}.`, }), // Bounded so a stalled endpoint cannot hold the exit, and with it Railway's restart. signal: AbortSignal.timeout(FAILURE_WEBHOOK_TIMEOUT_MS), }); } catch (webhookError) { - console.error("Failed to dispatch worker failure webhook", safeErrorLogDetails(webhookError)); + console.error("Failed to dispatch worker failure webhook", contentFreeErrorDetails(webhookError)); } } // Flush last: the process is about to exit and buffered events would be lost. diff --git a/worker/python/requirements-cloud.in b/worker/python/requirements-cloud.in new file mode 100644 index 0000000000..5a2341af79 --- /dev/null +++ b/worker/python/requirements-cloud.in @@ -0,0 +1,6 @@ +# Optional Python 3.12 bootstrap dependency input; production uses requirements.in. +-r requirements.in + +# quicksectx 0.4.1 (via medspacy-quickumls) requires Cython>=0.25,<3.2.0. +# Keep the resolver's build/runtime Cython selection within that supported range. +Cython>=0.25,<3.2.0 diff --git a/worker/python/requirements-cloud.txt b/worker/python/requirements-cloud.txt index ec68793505..2514da88a2 100644 --- a/worker/python/requirements-cloud.txt +++ b/worker/python/requirements-cloud.txt @@ -339,47 +339,81 @@ cymem==2.0.13 \ # preshed # spacy # thinc -cython==3.3.0 \ - --hash=sha256:03056533fe4fdbc4f1d34a39178f9a4937ff35196f8bcdde2a67b5b5809c61fe \ - --hash=sha256:03bc5333932f5dda3ba9315298ecdd21daa1b58410bb1f8ce04c78ec8337130a \ - --hash=sha256:0507d9caf7dc35f1212627145d5d13dbc5dd7128529a6608ab72690472fa688e \ - --hash=sha256:0deedc2e9a5a664e1adfa4c2d310aa7b54903e1a647c274b6c9213f77a02d637 \ - --hash=sha256:11e437f086affee8051cec4bb531be3edb646ab66e325154aa6849377f365033 \ - --hash=sha256:14e825253455e943ca765a95096b355745558436b0c46c24856de9269cc4dbd9 \ - --hash=sha256:169e56fd411f4cd5bba51c82f8239421d547a846099db2b261e4aed48ba9f51f \ - --hash=sha256:23942b0662642927a55676e4b26e6840fb166dd7d76436384685227e7e8619a4 \ - --hash=sha256:26a5e536fc68e85a9de091a0b51c42c5ac834f8d00aaa43f227cbc3efa797ae5 \ - --hash=sha256:29f38ebafdf23e3da2516f40c4d065da38bfe002181bf93e2b8cf1262449aba6 \ - --hash=sha256:311a016369adfd1e0015c4f9819168fc0e518451d7efb4435c30d65a3a26d52b \ - --hash=sha256:333449cc0350baedee5a6af27929eac8a71eac4ec59333c45ff476b33c6c660d \ - --hash=sha256:428fafed98ea26927000a287b4dfc9ef07339f56656a5329a34eaa593f79a4f8 \ - --hash=sha256:46072c0d404616b5e652a63882c79cc3f8a1d62635a8692f56ed0e416a4dfed8 \ - --hash=sha256:4a36c34d1950845b8ac148653b07cdc62421a4b0d9abfcc849e69f1c4ff9919d \ - --hash=sha256:51999fb834365721b6c7f689cf6e2ec7c8667aae783df9eb5e589c290a414d9c \ - --hash=sha256:596e8df019372a2cd417805015022d42cb8ee4e1803ccdc11ed00e451625fb66 \ - --hash=sha256:66d86b6a1548ae64851b211e3c3504535814b8c8e6c46ddcaf01062bf8d5fad2 \ - --hash=sha256:75c4ae8a6d3a5ccf3cdaba8ab32e6a8d0cd38e3a476aa7ac12df8f8171a8d570 \ - --hash=sha256:7e321ae700995a16dc3055ada06ffb8d61e1a7434e5d0e811547a45ac1015ebd \ - --hash=sha256:82f94565b6001bab8e31bf52a0911672910b5735910612a2c0f772c719670006 \ - --hash=sha256:843d7134e784e7b320ef387512e89f1b29af80c641e176dfa8eabd52aab61c3c \ - --hash=sha256:8566ea804cfc265f5e9dda71d1b716aa24ee4c3423a5da4b28a248a78c33e3f9 \ - --hash=sha256:90869072e50b7c8904fe1dd7810321ae901fd5637a6eec6646ed9c57f9eb1081 \ - --hash=sha256:9b24b5c8cd536946b62086fcafee6d5509d3f549f72d553d2336af87ffbe0da1 \ - --hash=sha256:ab24d1a4fb6aaf0b5b6fcd75a6d70255fbd3130fa78884c26991f8d5502616b5 \ - --hash=sha256:b447f6906e0555f05dc4742ef1f99091b1e5d9aa9f16616e772fbf9ff6271616 \ - --hash=sha256:b55c72e8eccdd508c8de3cf3bbc543aafbb3bf6a518e1ee20358d3241cd780ef \ - --hash=sha256:b94fb5613b9fe34c27d13ec9972dc0dcd2a2155db2902e93921cadc162610a38 \ - --hash=sha256:bc2f2a6b65a991666cfd35a35bab0cd88ffba4df2f601edb6e76cc8116de24b9 \ - --hash=sha256:bf411da3ef1af8763781c219108860f7de33f1100038da35d6bf1b4d83fcb2c0 \ - --hash=sha256:c4558ba85849ab65dc57e10fd0efb13fabd9d3c09981a2566e18dec7cf47586a \ - --hash=sha256:dce56c26d388f00a19426371b6926bf2f77c5c03b71d5273e4556c68be98c2dd \ - --hash=sha256:de883ec6764b61547c1e7674c0d8a8a875d398bd6bb684e46b93d83e4f13b260 \ - --hash=sha256:e0d2713d2b292c826bc21dc8732bd9e47628103aa3764180c881e04b3fef95dc \ - --hash=sha256:e6035b5231a9316edc19d6415f4296fd1d0370e2a165a714b3edc167b9ca00e1 \ - --hash=sha256:ec09dbf73ff4f7be2b339b995fadae9c4bb517bbbed7ec11d6fe99c2092b48fd \ - --hash=sha256:eda47eb7731c3b41180b58bb83de423f43aa58a677677e3390e8d332b003859e \ - --hash=sha256:eed0d93fbca7087f143b42c34b05a825849bdf17f101572c2105acfa49aa88b8 +cython==3.1.8 \ + --hash=sha256:0274d3c2f95f79784bccd5454ab9dd380aa5ce237e1dc685cc1ce149ac2ffbc2 \ + --hash=sha256:03959ed8996e16550267469187442f3926ea3ba4bb220a06714e6203e4cb6017 \ + --hash=sha256:062f1179af6c5eba92be6eef52baf46eb9cf0677a53aa6bf96ba7861482947a8 \ + --hash=sha256:08ba4010e2a96112c1cc3063c0f96246c33fdaeb937572a675229204745207ad \ + --hash=sha256:0aa5443cc8bfc937369871fbba59990b36cd9af73ed70db9052ac980d44492e4 \ + --hash=sha256:0bc71b05497608c1f8dc99340055c0456f712ce8a1cf35391c9134bbd037f7c6 \ + --hash=sha256:1be05353f84e6a32cbae5869da1af72d356cc73a6dfa521423543688f6b59d67 \ + --hash=sha256:1cbb54f823c6f06d05ec891de0b09ec99cf4fe64365c2b54ce2c918f7d209a4f \ + --hash=sha256:1d04bd43b80ed9ab6228193f24f287fe98b839270625051f7705cedd4e76ac8e \ + --hash=sha256:282b3c8e6abc3fea421919e862e898ffdd86fc0796009bdb5ffdf8211413219f \ + --hash=sha256:306cec0045c18b23152882a583c6181fa8d0c069a4aabf6e6b12bafb8600eed4 \ + --hash=sha256:34eb6dfaa0786a7477aaac86696f26efc052cda586d4f97a9d1d2c6b2ff6d26d \ + --hash=sha256:354f43c6d0e312f650ed39c683e550f669ed18f49ca0bd5772f207ef31f8980c \ + --hash=sha256:3a9eb5545c9ef0f5f6cfb864f70262e284df270de8cd5d73ee2097af0de432a6 \ + --hash=sha256:3d7df7d45733460167d96d9c093fe0abf5d4898aca8f05b7e4cd397e9c8f1d2e \ + --hash=sha256:45a5e806fbfb37c603c4cf0e60b7cd1165ad66bc3aa206f379606ca34dd93c42 \ + --hash=sha256:4673f213e692b9502de8d25cd8f2d8aa3c0ea096546b5f6f019a7ec55df0a56d \ + --hash=sha256:4fa23531b04d63ba2fac0dbf577323ac5c40358ea3ec6bc2b98445bf418faca7 \ + --hash=sha256:50ec9dac15bd8bdb276fd38a146c5294184330551bc1fb053e7e04ff66b7c756 \ + --hash=sha256:518475fbe633ba30c1577669f417440f41e1a23ad6b6e2dbb20703cd457f04ef \ + --hash=sha256:53e5bbf18c274d99dec3e75e1ef19c0add393c8fa14e3c5f1f7ed7fb88e9621d \ + --hash=sha256:554ce2d8243e9330c21ed6e8a5472f1fa812afb99794581c4276b88bd523af5d \ + --hash=sha256:58e21933b3b568817a2896767577ff8a31a246f5c6a5589c73469d83f2bcbfd5 \ + --hash=sha256:5e10d15d2e7f245e61dec89c27da377c2136c08efb6cc2c50baea25b39aab3f9 \ + --hash=sha256:6221417b160e9172aae69b0fd397e02528a6076e1fec642a181aa11c64e32cc9 \ + --hash=sha256:658b8d9f7261a79aa66cf2a5eb0fd7a9b8e196f21671bfc945765a615205d775 \ + --hash=sha256:65d0cf78a14616cb05ff21f63b47bf369e23f353acd51420526c5eb3029f682a \ + --hash=sha256:6ee0772182f6c943d8224a043f00da2c1a8f62cae7de13d02044ef7865035745 \ + --hash=sha256:70ac13ab4bf7e9c1285172d36f9bb06644bb84dc6d4fc0e156ffb8d04b9df86a \ + --hash=sha256:791414cca4dc04eb95b928da88154d8bf5164f85a645816291e2f549c5e8b935 \ + --hash=sha256:7cca0af72f79866994aa7e79ee72d11b8618313c2a4f9405a6943b3e1186952e \ + --hash=sha256:7eb8448eb1c3632c4684ee98db935cf7d3b54fb528a7a455841789316667cd88 \ + --hash=sha256:850503d88092af519662cf66aef26f95db677bdbba55a482ae8658c5b3ac6ad4 \ + --hash=sha256:85c39a8005f314ee90ab271d2b68b6725f94fc08c4e6728417b64704a7ebce04 \ + --hash=sha256:8d10dc3b84b1fe6a7edd43c5bb09cbbf98a7e14f235972e895d029dfcb0a9ce0 \ + --hash=sha256:8f827bf462359eec838e4462c1d819e0008e82dc53df107be13e7617bc96821d \ + --hash=sha256:92a8df673744117afe2a4cb979887ca9440f9e59cbb2e9d0753db2194e2a5080 \ + --hash=sha256:98f45565771122c3f1604b057a5c506a7e682f99ec9d44197cb2990117ff4869 \ + --hash=sha256:9f9af578c0a3ae0a10a8cf41805d0dbec173f909a297368f30090a55a5a9cbe0 \ + --hash=sha256:a9288fa02135743719942deaeb44976399a5785a59f076221ef7e9b22cd0d571 \ + --hash=sha256:b008f7802789bafcdd61de66e11697ee6807839154fc1bf72d81705500997551 \ + --hash=sha256:b690edfa6b333f6139e8ecaa5af8fe87ea591f04d2f2b6a492ed4f2896d818c4 \ + --hash=sha256:bbd835515316a0d014ce27256fb7ceda052d14ac32c242d3f1d6d41734ba3c28 \ + --hash=sha256:bc4fdb56b64f035025bf6958cbd22a61fce1668a2b19ec47aa418df5e9b6b61d \ + --hash=sha256:c27c5c5d675ecabfce60f27a24024df20472b90735d57ce3b869685606b31617 \ + --hash=sha256:c6b74c42bf6b2381d9fdb65f7eda44bcc5c29e4e96c677167495d2cd882d7a3c \ + --hash=sha256:c8803c6b42d482f894cd2ff50f334cc27f70029a9177f7183cac1e5da3978edd \ + --hash=sha256:cae61e8e730bfc7de917204c8b7c1ca8e3e0d134dcaa55a2e2ef98de741590fb \ + --hash=sha256:d114a15b1660ffc8456baf6bfff836fbe2f9c523954de6913040cf6cea1b2349 \ + --hash=sha256:d1bcbed7578f1986a61471476ea5deb0806591aa477230954dd9898a25d0f4e2 \ + --hash=sha256:d249b8ba93cdc0172de325a48f122feec73c03f31d88a738995499a2153f98c4 \ + --hash=sha256:d379eb20a48186573c1371b9f7ae45486f3cc378c3b8f5a38e005cb907596ef2 \ + --hash=sha256:d9029c0eb895a56746ca0d4b3a6a0a57e4c90f2599204e99f7033818ea1c3022 \ + --hash=sha256:d973d25a767abcf977737221dc11befda98daf2573e8bc3c2e6995479505f92a \ + --hash=sha256:d9cd8d1194ab8f52898be10d252ff17360e0af0e2a6662a8d9688fdd8e4cdd7e \ + --hash=sha256:ddab120e53aab67c098416ac5a1467a01f03360aa75eb21a0064b85e9d621be7 \ + --hash=sha256:ddc98eea4a396e903c47ecb8a9defa39b951dd8ccae40e425fe32ec953e228d8 \ + --hash=sha256:de5613a09f3cf056561528a0146aa3529920ce711728fb28d5399180c52ec520 \ + --hash=sha256:e04bb68f932853a38bab7f7c024088a9f6510ccb45ad9a12cca6ebb09ec7a25f \ + --hash=sha256:e153a5aa8f96b79f6e4b9a3d221d4f38b0f7f00a1462dec01478ab99075abd4a \ + --hash=sha256:e1715bf5838c43ef3abfc41d6552a1a5d4bc4d7b041a5d6833080d6b514cc590 \ + --hash=sha256:e26239169b0332eda9a00de22db1fb9d5eb51b67f0af6f19a45205bd119a2e12 \ + --hash=sha256:e3d59036be47961fba075067847abd16c82f5a45ea96ba709b1c485910c20e2e \ + --hash=sha256:e6f3a46fdc5b6da9481b81da6e0f8f3c04cb161baf6956e185a4f242fa1242e2 \ + --hash=sha256:ec653d499d02ada3d150894e840018a9fc82cc5f8b9ac3af00066a8c1f76e0bd \ + --hash=sha256:ef3000be84941e5b597579d3f753460d1e058c32a8697041f78a827797037280 \ + --hash=sha256:f36368c1d7ea4003c52e9e93f3556074a631a6482f8a32e781cb073e211e4df9 \ + --hash=sha256:f519dd0b076bc78edf3b7ab0722638b42d5ab43323654fbfe2fbf63216a3e854 \ + --hash=sha256:f6afffe05b2db113f6502c5b652e0ad5158584e185a51538beeffa8771bdb94d \ + --hash=sha256:f7c3678b89689a367c19fc7c09220a077c6c31c710728ab1aaf4b114c830ee15 \ + --hash=sha256:fc28b2933f2c3c897e1a27d0dee909d9731fa1d19fda3f1d8908dddf31825d2f \ + --hash=sha256:fc3cbaee08d424d2852c7b134ca432501792f67021b73a029cf2f13fb6172423 # via + # -r worker/python/requirements-cloud.in # pyfastner # pyrush # quicksectx @@ -1583,36 +1617,47 @@ srsly==2.5.4 \ # spacy # thinc # weasel -thinc==9.1.1 \ - --hash=sha256:082d3804a3dc5091bc3c980f338b37d4d848454f018b42be7dec7c042847c3c4 \ - --hash=sha256:09c15bae71d5ee215c7b5240eee859a16713de47e9250879a207f043e0c299b3 \ - --hash=sha256:19928387bf7e80e2f808fa02c268b4e593d118f4722b3c1df1d7f53ef79d410c \ - --hash=sha256:1b680e45eea8183984a717825b5f7594430674d019b66e4f959f6aba54d27425 \ - --hash=sha256:21fae2986d7777a6f050b9046dc9eab11852f1c9a997dcc9032f3ecc22784a4a \ - --hash=sha256:2b47bd3af0f68f52393a6ae76d28718260533fcf9f5e010a94a302e67611841c \ - --hash=sha256:34fe74c3d6682983fcbff49634f77c353e48fa6fd4b9e0fbd4075cec583523b5 \ - --hash=sha256:3d305f06b1dedd40ee942f629c63cd36b299e4368b9b1223f8952366e6b3ed3a \ - --hash=sha256:3e962f139b3e353d58ef87a861ba7e890bb29655c317bd225b568b97272a4b99 \ - --hash=sha256:45379a56d569f0a56a90d15fded5e46c8b1abb0a1b2617450194f06a55abf827 \ - --hash=sha256:49f316e599d71a91d6b0d8737015c7cc04cd8a10ca0ec412abc66212a4011dd4 \ - --hash=sha256:4ab269b1646c5cc058055d4941e343aa52b293225c31775a94373e02bc5c85a1 \ - --hash=sha256:4fa86a66854851ff51f7f5d32afe4c284582fc840009f11b9070f6aaf9455006 \ - --hash=sha256:7264dedc732541fd2783055072580b0eb06aecd9c102de639f2f53b6078127d1 \ - --hash=sha256:754fb8c6d3f9f7ba9503a8969f007d3a171d49989aca5a435511f06c5e1171a0 \ - --hash=sha256:7bd43399c9f3b6f5ec2e429a96cb7e2320c7f8db4a358715ba177b5a1325f0c7 \ - --hash=sha256:8a458c2ce71d74615acf491b717342821133eca4a36d172a8c048b46a92566bd \ - --hash=sha256:9c4a19eab36f246f49c3fd662a4442e07839a3f93a34b48867c9857587f9c518 \ - --hash=sha256:9c88e10e75607869425ed11d29c9a0754c5a8ed44048ea7bda00fb49a2d287a0 \ - --hash=sha256:a540ce68287939d5fcbc25e67c18d0e8b5e9f700e7075c28c2e40aa51496d54f \ - --hash=sha256:b03c7f88363bbbcef2a36b8da261c1ad69972ffe2f6c2aaf1accef5c2d749d1a \ - --hash=sha256:b3f6dceb9605f5aaa2b57e16b336866e8bed235fe1224612e51657c16e815480 \ - --hash=sha256:c7aef2cf91f53bebe2be52ff46f7b698da32e04e2ee53929aab6b1ff1af8157d \ - --hash=sha256:cf25ac6bf36bc8f46b7a1234a2be74469fe54fa6d1421fbb9deb968614ab03a6 \ - --hash=sha256:d3f16766f25cdf11fb136688d52df5d4698c30532cf42812192186b476833a33 \ - --hash=sha256:d4c30a139df83f076b79b8701ab5544ef49d683696d1a71cc5adcf2d24d73528 \ - --hash=sha256:e77f0877c567103601e58178bb0d24d227a081380e25ed8ab4d2f2f7438d6d6a \ - --hash=sha256:f1f9844a881f5892a23e23f0a112be3a6b0feedd4ac928f96ce32c6784fcaeb8 \ - --hash=sha256:fc24dadedfe2eeaa1c6a4a1b08d30a4fa81185527dfa5fdf602e334b23bc0911 +thinc==8.3.13 \ + --hash=sha256:0355c37e40d1a9fc2a1b8e9c2e294d8586f6baa97bcac6b9002f2dddb4b82ae9 \ + --hash=sha256:0a0fa13dcfe4b319c3a396432c1dbff30d3de37dbbdee559e76600ee2b9486df \ + --hash=sha256:11754fada9ad5ba2e02d5f3f234f940e24015b82333db58372f4a6aedad9b43f \ + --hash=sha256:303477eb51b9b39c94a7fc7967ee8a039eca1ca37d95dcce1234c83b95b4ee9f \ + --hash=sha256:3710d318b4e5460cf366a6f7b5ddbefb5d39dbd4cfa408222750fdc6c27c4411 \ + --hash=sha256:3dac18a0fb0a42f711c2ce9c02cbb090385aecae92089aa17b9dfd808a542013 \ + --hash=sha256:433e3826e018da489f1a8068e6de677f6eff3cc93991a599d90f12cd1bc26cdc \ + --hash=sha256:4565102638038a01a2193c7f5d41ccbd6233fbdcb1f1b184322a06add4f51f18 \ + --hash=sha256:4b5ec9ff313819e7d8667794a3559463fa89ff45aaa73e3fd8d6273b1e0d7a7f \ + --hash=sha256:5593e6300cb1ebe0c0e546e9c9fb49e7c2627a0aa688795cd4f995a8b820d2ec \ + --hash=sha256:565300b7e13de799e5abff00d445f537e9256cf7da4dcb0d0f005fc16748a29e \ + --hash=sha256:5a08c87143a6d20177652dca1ec0dc815d88216d8fc62594a57e8bc45bf5ed49 \ + --hash=sha256:5c9a48f2bc1e04f138240ed5f9b815a9141a5de26accd0f08fa0137fcefed258 \ + --hash=sha256:68e658549fc1eb3ff92aed5147fcbb9c15d6e9cc0e623b4d0998d16522ffb4f9 \ + --hash=sha256:723949cab11d1925c15447928513a718276316cec6e0de28337cca0a62be0521 \ + --hash=sha256:77a41f66285321d20aaedaea1e87d7cd48dca6d2427bed1867ec7cba7109fc8d \ + --hash=sha256:79a29a44d76bd02f5ac0624268c6e42b3576ae472c791a8ae9c2d813ae789b59 \ + --hash=sha256:7a99d0e242d1ccd23f9ae6bea7cd502f8626efa65c156b91d84581d0356696c3 \ + --hash=sha256:7badb0be4825535e6362c19e8a41872b65409e9da46d3453a391b843a0720865 \ + --hash=sha256:81337dfbee37f58f36c0c70f9a819dce1b32cdc13d959181e10de079621f6ac6 \ + --hash=sha256:84fb50fe572a1860165f2e7a640c7cb70d43d6962366e69f643fa9a27e4a2127 \ + --hash=sha256:859fbd9d9b16af5278da23589b4afbe2ab6b0dd615df4d3229b7c4e67cd3107e \ + --hash=sha256:8ad40307f20e83f77af28ff5c6be0b86af7a8b251d1231c545508d2763157d8f \ + --hash=sha256:a518d5c761a0f2341e530e867de133dc3ed814558365b2a68ec53b89c482a43f \ + --hash=sha256:a61a31fd0ce3c2771cf4901ba6df70e774ffe32febf1024c5b43d63575cd58fe \ + --hash=sha256:ba8119daf84a12259ae4d251d36426417bafa0b34108890b4b7e2b50966bd990 \ + --hash=sha256:c17cef1900a1aba7e1487493d16b8aa0a8633116f1b2a51c6649a4000697f17b \ + --hash=sha256:c2811dfd8d46d8b5d3b39051b23e64006b2994a5143b1978b436938018792af8 \ + --hash=sha256:c6a049703a6011c8fe26ee41af7e70272145594140d82f79bb23de619c6a6525 \ + --hash=sha256:cd8a2b714c061969eee65802965167a6ada1fe708d82fe176d98dcb95ebe182a \ + --hash=sha256:d7a9654f9ca362a4be7f5e590fdfee26e2e2084da9fd3306032ec037e99f2f8e \ + --hash=sha256:e08b1577a56e7315770af280aabd8fa5f2a1fb6afd1c50a4183c06e907faf558 \ + --hash=sha256:e1f8d13bf92ee10595c40692fd4cf8e7bbe73bd9f260107e975fd5dbee1af42b \ + --hash=sha256:e676edd21a747afbe3e6b9f3fca8b962e36d146ded03b070cb0c28e2dfbe9499 \ + --hash=sha256:e7f046d8914055cad51e83ff0da1a892acb73cd58556d7c1a5d4015a3766a899 \ + --hash=sha256:e9c7c5c104737b414c8c4ec578e67d78b6c859afe25cbc0684402e721415bd7f \ + --hash=sha256:ed1dc709ac4f2f03b710457889e4e02f05de51bc8456980c241d0b28798bc7cb \ + --hash=sha256:f4f26d1eec9b2a6a8f2e0298a5515d13eb06d70730d0d9e1040bb329e12bf3fb \ + --hash=sha256:f697174d3fb474966ce50b430bbafa101a6d2f7ffb559dac4b5c59389ef72d22 \ + --hash=sha256:fbc0ee16edd260c6a4a9e365ff36d0a682c9e7ca6d7b985682659ef2e3e73826 # via spacy tqdm==4.70.1 \ --hash=sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73 \