From eb9e75e0e7ff9729da2a4c5642c02ba5341498a5 Mon Sep 17 00:00:00 2001 From: shawliu998 <260819717+shawliu998@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:52:01 +0800 Subject: [PATCH 01/10] docs: audit legal matter convergence baseline --- THIRD_PARTY_NOTICES.md | 37 ++ docs/convergence/current-state-audit.md | 555 +++++++++++++++++++++ docs/convergence/data-migration-plan.md | 197 ++++++++ docs/convergence/open-source-reuse-plan.md | 109 ++++ docs/convergence/target-architecture.md | 330 ++++++++++++ docs/provenance/open-source-inventory.md | 22 + 6 files changed, 1250 insertions(+) create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 docs/convergence/current-state-audit.md create mode 100644 docs/convergence/data-migration-plan.md create mode 100644 docs/convergence/open-source-reuse-plan.md create mode 100644 docs/convergence/target-architecture.md create mode 100644 docs/provenance/open-source-inventory.md diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..f677841 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,37 @@ +# Third-Party Notices + +Vera is distributed under `AGPL-3.0-only`; see `LICENSE`. + +## Open Legal Products Mike + +Vera contains controlled source adaptations from Open Legal Products Mike: + +```text +Repository: https://github.com/Open-Legal-Products/mike +Commit: e32daad5a4c64a5561e04c53ee12411e3c5e7238 +License: AGPL-3.0-only +``` + +Source provenance, the approved adaptation rules, and affected areas are listed +in `docs/mike_port_manifest.md` and `docs/license_attribution.md`. Original +copyright and provenance comments must be retained. + +## SQLCipher Node binding + +Encrypted Workspace database mode uses `@signalapp/sqlcipher` 3.3.9, licensed +`AGPL-3.0-only` according to its installed package metadata. Its package license +is included with the installed dependency. + +## Other dependencies and optional runtimes + +JavaScript dependency versions are fixed by the repository lockfiles and carry +their own license metadata and distributed license files. The current Legacy +voice adapter can use an operator-provided faster-whisper installation and model; +neither that toolkit nor model weights are bundled or approved for redistribution +by this notice. The Legacy Word proof-of-concept references Microsoft's hosted +Office.js runtime and will undergo a separate terms and security review before +the target Word integration is released. + +The maintained convergence inventory is +`docs/provenance/open-source-inventory.md`. Candidate projects listed there are +not incorporated merely because they were evaluated or mentioned. diff --git a/docs/convergence/current-state-audit.md b/docs/convergence/current-state-audit.md new file mode 100644 index 0000000..e9664e4 --- /dev/null +++ b/docs/convergence/current-state-audit.md @@ -0,0 +1,555 @@ +# Vera Legal Matter Agent Convergence — Current-State Audit + +Date: 2026-07-16 +Audited baseline: `main` / `origin/main` at +`12af6fc53317e96314a980250d3bd12d5bfd3bcb` +Audit method: source, migrations, routes, runtime composition, UI clients, +tests, local Git provenance, and executable baseline gates; README claims were +not accepted without code/test evidence. + +Line references in this document refer to the audited baseline above. Phase 1 +changes may move the same code. + +## 1. Executive finding + +The active Mike-derived Workspace is real, local, encrypted, and substantially +more advanced than a static shell: Project/document persistence, durable +Assistant, workflows, tabular execution, immutable source snapshots and +citation anchors, OCR provenance, Document Studio CAS/versioning/suggestions, +model settings, and packaged desktop security all have executable gates. + +The repository is not yet one converged product runtime. The principal blocking +fact is that the production desktop backend still loads and mounts Legacy +Aletheia alongside Workspace on every normal start. Router construction opens +Legacy database handles and initializes model/voice control objects; bootstrap +also configures the Legacy durable runtime. Hiding `/aletheia/*` from the main +navigation did not isolate it. + +The current Workspace schema is v14, not the older v8 state suggested by parts +of history. The next additive migration must be v15. None of MatterProfile, +Matter Artifact, unified Review Inbox, Work Queue, Conversations, bounded legal +Agent Run, validation/stale, Word bridge state, or Legacy migration ledgers +exists in the active Workspace schema today. + +## 2. Classification vocabulary + +| Label | Meaning in this audit | +| --- | --- | +| `reuse` | Keep the implementation and its tested contract as an active foundation. | +| `adapt` | Extend or move the implementation while retaining one canonical owner. | +| `migrate` | Transform existing data/contract into the target model with lineage. | +| `isolate` | Keep for compatibility/tests, but remove from default active composition. | +| `delete-later` | Delete only after data, backup, replacement, and regression gates pass. | +| `do-not-use` | Must not become a dependency or state boundary of the converged product. | + +## 3. Workspace schema + +### Confirmed version + +`backend/src/lib/workspace/migrations/index.ts:1-60` registers a strict, +contiguous v1-v14 chain: + +```text +v1 initial Workspace +v2 Workspace integrity +v3 Workspace runtime +v4 Project ownership +v5 Assistant runtime +v6 Workflow runtime +v7 Mike tabular semantics +v8 model credential origin +v9 model connection readiness +v10 durable Assistant events +v11 Project source foundation +v12 Document Studio +v13 source retention lifecycle +v14 Document Studio suggestions +``` + +The executable migration audit reported `current_version: 14`, ordered +SHA-256 checksums, idempotent rerun, transactional rollback, v1/v2/v3/v6 +upgrade coverage, SQLite/SQLCipher integrity, and preservation of a Legacy +Aletheia sentinel table and row. + +Classification: v1-v14 runner and invariants are `reuse`; new legal domain +tables are `adapt` starting at v15; editing an old migration is `do-not-use`. + +### Missing target tables + +No active Workspace migration defines any of the following: + +```text +matter_profiles, matter_policies +matter_artifacts, artifact_revisions, artifact_source_links, artifact_relations +review_items, review_decisions +work_items, work_item_links +conversation_sessions, conversation_participants, transcript_segments, +speaker_bindings, conversation_extractions, conversation_processing_runs +validation_runs, validation_findings, artifact_dependencies, stale_markers +agent_runs, agent_run_events, agent_tool_calls, agent_approvals, +agent_artifact_proposals +word_document_bindings, word_sync_sessions, word_operations +legacy_migration_runs, legacy_migration_items, legacy_migration_errors +``` + +Legacy has similarly named capabilities, but those are not acceptable as new +Workspace storage. New modules writing Legacy tables are `do-not-use`. + +## 4. Current `/api/v1` surface + +`backend/src/veraApplication.ts:451-499` builds one authenticated Router, +applies the audit mutation guard, and mounts it exactly once at `/api/v1`. +Ordering deliberately keeps workflow routes ahead of generic `:id` routes. + +The confirmed route families are: + +| Family | Current routes | Evidence / classification | +| --- | --- | --- | +| Projects | `GET/POST /projects`; `GET/PATCH/DELETE /projects/:projectId`; archive/unarchive | `workspaceV1.ts:921-1004`; `reuse` technical boundary, `adapt` product naming. | +| Project documents/folders | list/upload/update/version/retry/delete documents; CRUD folders | `workspaceV1.ts:1008-1197`; `reuse`. | +| Global documents | `/documents/**`, `/single-documents/**`, read/display/url/download/retry/version/delete | `workspaceV1.ts:684-919,1199-1200`; `reuse` compatibility. | +| Download capability | `GET /downloads/:token` | `workspaceV1.ts:1201-1213`; `reuse`. | +| Chats/Assistant | `/chat`, Project chats, create/detail/update/delete; generation submit; durable job list/detail/events/cancel/retry/regenerate | `workspaceChatsV1.ts:348-632`; `reuse` runtime, `adapt` outputs to Proposal/Review. | +| Workflows | CRUD, capabilities, hidden state, definition, runs; run detail/cancel/retry | `workspaceWorkflowsV1.ts:410-631`; `reuse` runtime, `adapt` legal steps/review outputs. | +| Tabular | list/create/detail/update/delete, clear/generate/regenerate/cancel, optional chat routes, export | `workspaceTabularV1.ts:403-611`; `reuse` as Matter Work; global UI becomes compatibility. | +| Settings/models | settings status/read/update; model profiles; write-only credential; test/activate/deactivate; delete | `workspaceSettingsV1.ts:534-671`; `reuse`, then `adapt` privacy profile. | +| Sources | capture document snapshot; list/detail/content; create anchor | `workspaceProjectSourcesV1.ts:435-528`; strong `reuse`, then `adapt` source kinds/locators. | +| Studio | create, Assistant/Workflow handoff, get/CAS save, DOCX import/export, versions, suggestions, accept/reject/restore | `workspaceDocumentStudioV1.ts:817-1071`; strong `reuse`, then unified Review adapter. | + +All Workspace routes are loopback/auth protected in production. The API has no +Matter/Profile/Artifact/Review/Work Queue/Conversation/Agent/Word Bridge route +family yet. + +## 5. Current `/aletheia/*` surface + +`backend/src/veraApplication.ts:439-449` unconditionally mounts the mutation +guard and ten Legacy router groups: + +```text +aletheiaRouter +legalResearchRouter +legalResearchIssuesRouter +legalOpinionsRouter +litigationRouter +durableAgentRunsRouter +localGovernanceRouter +localModelsRouter +createLocalVoiceRouter() +createAletheiaLocalControlRouter() +``` + +The base router alone exposes security policy, tool-adapter tools, matters, +search, work products, review/approval, memory/playbooks/skills, agent runs, +evidence/issue/draft operations, source index, export/eval, and Legacy document +operations (`backend/src/routes/aletheia.ts:504-2059`). Litigation, legal +research/opinions, tasks/deadlines, document drafts, authority versions, and +voice add further route families. + +Classification: the whole public surface is `isolate`; selected stable +algorithms are candidates to `adapt` only after extraction; active modules +calling a Legacy route/table are `do-not-use`; unreachable routes are +`delete-later`. + +## 6. Composition root, WorkspaceRuntime, and Electron lifecycle + +### Active desktop lifecycle + +- Electron creates a per-launch random bearer and binds both services to + `127.0.0.1` (`desktop/main.js:96-105`). +- It starts a Keychain credential utility, backend utility, and frontend utility + in an ordered, supervised lifecycle (`desktop/main.js:1128-1224`). +- Renderer start route is `/assistant`; the frontend root also redirects there. +- The renderer is sandboxed, context-isolated, without Node integration; browser + permissions, navigation, and unexpected windows are denied + (`desktop/main.js:614-705`). +- Pending restore reconciliation happens before renderer creation. + +Classification: Electron process supervision, bearer, loopback and renderer +security are `reuse`; Aletheia-named environment/config paths are `adapt` over +time, not destructive rename in Phase 1. + +### WorkspaceRuntime + +`WorkspaceRuntime` is one large constructor/composition object +(`backend/src/lib/workspace/runtime.ts:392-831`). It constructs database, +repositories, services, model gateway, Assistant, workflow, tabular, Studio, +source-retention, recovery, and one job pump. The pump handles +`document_parse`, `assistant_generate`, `workflow_run`, and `tabular_cell` +(`runtime.ts:752-791`). Startup runs migrations, retention sweep, credential +reconciliation, pinned workflow seeding, recovery, pump start, and job +reconciliation (`runtime.ts:833-858`). + +Classification: the single database/pump/runtime behavior is `reuse`; the +constructor is `adapt` into module factories. Adding more unrelated services +directly to this constructor is `do-not-use`. + +### Legacy initialization on normal start + +The current normal backend sequence is Workspace start, unconditional Legacy +durable configuration, Express construction, optional demo seed, then listen +(`backend/src/veraApplication.ts:731-756`). This has material side effects even +when the Legacy UI is hidden: + +- `createLocalVoiceRouter()` constructs a Legacy repository and voice runtime at + route composition (`routes/localVoice.ts:108-115`). +- Legacy repository construction opens the shared SQLCipher database and runs + the large Legacy schema (`lib/aletheia/localRepository.ts:906-924,1424-1442`). +- local-control router construction opens another Legacy handle and creates + provider/MCP/calibration state (`localControlRepository.ts:271-320`). +- local model scheduler objects are initialized; with configured models the + durable runtime can call `startModel()` and start its worker even when the + older autostart setting is false (`durableAgentRuntime.ts:81-99,282-299`). +- voice Python sidecar is request-lazy, but its runtime and temp-directory state + are constructed (`localVoiceRuntime.ts:240-309`). +- production demo seed happens to stay off because bootstrap requires + `ALETHEIA_ENABLE_DEMO_SEED=true` plus non-production, while desktop passes a + differently named variable and forces production + (`veraApplication.ts:663-666`, `desktop/main.js:1154-1185`). + +This is `isolate` and is the Phase 1 blocker. Merely returning 404 inside a +handler would be insufficient because constructors/imports already cause work. + +## 7. Active product navigation and UI + +### Top level + +The actual primary navigation is: + +```text +Assistant -> Projects -> Tabular Review -> Workflows -> Settings +``` + +Evidence: `frontend/src/app/components/vera-shell/VeraSidebar.tsx:24-38`. +There is no global Work Queue. The target `Matters / Work Queue / Workflows / +Assistant / Settings` therefore needs route composition, not a cosmetic rename. + +### Project + +The actual Project navigation is: + +```text +Documents | Assistant | Workflows | Tabular Review +``` + +Evidence: `ProjectWorkspace.tsx:456-506`. There is no current Matter Overview, +Sources page, Case Map, Work composition page, or Activity page. + +The Project Provider uses real APIs and polls parse state +(`ProjectWorkspace.tsx:229+`). Project-scoped Assistant rejects cross-Project +chat binding. Workflow and Tabular operations use durable APIs/SSE, not browser +mocks. Studio is a real document route with offline/conflict/save states. + +Classification: shell and product components are `reuse`; navigation and +Projects-to-Matters presentation are `adapt`; old global Tabular remains +`isolate` as a deep link. + +### Legacy UI + +Legacy pages remain directly reachable under `/aletheia` for matters, +litigation, AgentOps, tasks, reviews, evidence, audit, settings, and other +compatibility routes. Its shell exposes Matters and Work Queue, while litigation +has Overview, Facts/Evidence, Positions, Research, Procedure, and Artifacts. +These views are not integrated with Workspace Project/source/Studio state. + +Classification: `isolate`, then selected data/algorithms `migrate`/`adapt`, UI +`delete-later`. + +## 8. Real active data structures + +### Project, Document, Chat, Workflow, Tabular + +`backend/src/lib/workspace/types.ts` is the active typed domain contract: + +- Project has stable ID, name/description, optional case-management/practice + metadata, active/archive/delete state, default model profile, and timestamps + (`types.ts:107-118`). It is a general technical container, not a legal Matter. +- Document belongs optionally to Project/folder and points to an immutable + current version; versions carry content SHA-256 and page count + (`types.ts:129-154`). Chunks carry version, offsets, pages and OCR metadata. +- Chat is global or Project-scoped; messages are durable and may bind generation + jobs (`types.ts:190-220`). Message sources carry document/version/chunk/quote + data but are not the full Snapshot/Anchor contract. +- Workflow is assistant/tabular with bounded prompt/document-context/tabular + column/output steps. The type explicitly excludes arbitrary code, shell, + network, and dynamic tools (`types.ts:246-331`). Runs pin Project/model/job and + persist steps/output/error (`types.ts:333-359`). +- Tabular Review may be Project-scoped and persists documents, columns, cells, + status, model, jobs and source references (`types.ts:361-415`). + +Classification: these are `reuse`; Assistant/Tabular source references must +`migrate` through a shared Snapshot/Anchor adapter before becoming formal legal +provenance; chat/tabular cells as Matter memory are `do-not-use`. + +### Source Snapshot and Citation Anchor + +v11 currently supports only `project_document` and `legal_authority` +(`sourceFoundationContractsV11.ts:10-24`). Snapshot fields include Project, +source/version IDs, frozen title, content SHA-256, strict path/secret-free +locator, retrieval time, data-use/license policy, retention, expiry, retrieval +metadata, and creation time (`sourceFoundationContractsV11.ts:232-287`). +Anchors bind Project, snapshot, ordinal, exact quote, strict locator, timestamp, +and derived quote SHA-256 (`sourceFoundationContractsV11.ts:292-307`). SQL +triggers make snapshots/anchors immutable and repository/service reads recheck +hash/ownership. + +Classification: strong `reuse`; new conversation/email/note locators are +`adapt`. Creating a second voice/email provenance model is `do-not-use`. + +### Document Studio + +v12 distinguishes `source`, `draft`, and `template`; Studio versions are +`user_upload`, `assistant_edit`, or `user_accept` +(`documentStudioContractsV12.ts:10-22`). Drafts are Project-scoped Markdown with +immutable versions, content hashes, blob locator, operation ID and citation +anchor bindings (`documentStudioContractsV12.ts:185-269`). Saves and restores +use strong current-version CAS; restore appends a version. v14 suggestions are +pending-only, exact-range, source-linked proposals; accept uses exact splice and +creates one immutable `user_accept` version atomically. + +Classification: strong `reuse`; unified Review stores references to Studio +suggestions and delegates acceptance to existing CAS/exact-splice logic. + +### Citation split that must be resolved + +Studio citations revalidate snapshot/version/chunk/offset/quote/hash/page via the +shared source API. Assistant citation wire lacks snapshot and quote hash; +Tabular source refs have version/chunk/offset/page/quote but no snapshot/quote +hash. Existing records need compatibility projection; neither weaker wire can +be copied directly into a formal Artifact source link. + +## 9. Legacy domain inventory + +The Legacy tree contains substantial real implementations, not placeholders: + +| Capability | Current implementation | Decision | +| --- | --- | --- | +| Matter/work products/review/audit/approval | `lib/aletheia/localRepository.ts`, `repository.ts`, `domain.ts`, base routes | `migrate` data; `adapt` only extracted audit/approval patterns. | +| Facts/evidence/issues/claims/positions/decisions | `litigationStore.ts`, `litigationDomain.ts`, `routes/litigation.ts` and large typed frontend client | `migrate`; never make new Workspace depend on Legacy tables. | +| Tasks/deadlines/calendars | litigation task/deadline/rule/court-calendar modules and routes | extract bounded calculation as `adapt`; task data `migrate`. | +| Review/finding validation | position/finding/output review, entailment and approval code | evaluate and extract stable algorithms; Legacy review store `migrate`. | +| Legal research | provider/adapters, issue tree, broker/gate, opinions, authority versions | controlled adapters/policy may `adapt`; Legacy routes `isolate`. | +| Durable agents/models | durable executor/runtime, local model scheduler/calibration | `isolate`; new bounded Agent Run uses Workspace jobs/model gateway instead. | +| Voice | local voice route/runtime/protocol and Python faster-whisper adapter | `isolate`; imported Conversations are new; optional algorithms require license/security review. | +| Drafts/export/Word POC | litigation drafts/roundtrip/export plus `/office/word` and `office-addin/` | bounded export can `adapt`; data `migrate`; Word POC `isolate` until dedicated bridge. | +| Eval replay | Legacy eval cases/runs/annotations and audits | schema/data `migrate` or extract after definition review. | + +Legacy contains parallel source/evidence concepts. They must be mapped into the +active Source Snapshot/Citation Anchor model rather than copied. + +## 10. Security, encryption, backup, and audit + +### Confirmed reusable controls + +- Packaged desktop requires application file encryption and SQLCipher and + rejects downgrade (`desktop/encryptionPolicy.js:23-57`). +- SQLCipher connection verifies cipher version, readable schema and + `cipher_integrity_check`; the integration audit also proves plaintext cannot + read the migrated database, wrong keys fail, and the native addon hash is + pinned (`lib/aletheia/localDatabase.ts:95-245`). +- Workspace blobs require encrypted codec, UUID locator, no-follow/exclusive + writes, fsync/no-clobber publish, and plaintext hash/size verification + (`localWorkspaceBlobStore.ts:154-297`). +- Model-profile secrets go through an isolated credential utility and macOS + Keychain; renderer cannot read them and secrets do not enter argv + (`desktop/macOsKeychain.js:216-260`, `credentialWorker.js:186-199`). +- Workspace authentication checks the exact `/api/v1` boundary, socket-level + loopback and constant-time bearer comparison + (`middleware/workspaceAuth.ts:257-285`). +- Backup rejects symlink/hardlink/special-file/path traversal and read-time + changes, authenticates AES-GCM before manifest use, and restores through a + staged rollback/pending-record exchange (`desktopBackup.ts:176-250, + 1051-1188`; `desktop/main.js:299-363`). +- Source retention rechecks around final DOCX conversion, and `local_only` + cannot pass an unknown model context (`runtime.ts:615-627,1734-1756`). + +These controls are `reuse`. + +### Confirmed gaps + +1. Pre-Phase-1 health only reports Workspace pump and audit; it does not + distinguish Matter, Conversation, Legacy routes/runtime, model, voice, + credential worker, storage or retention (`veraApplication.ts:501-544`). +2. Audit anchoring is default-off and high-assurance-only fail-closed. HTTP + mutation guard exists, but Workspace background pump/final commits do not + receive that gate; anchor snapshots cover Legacy audit tables rather than a + canonical Workspace mutation ledger. This is `adapt`, not a completed + all-write fail-closed claim. +3. Legacy provider secrets can still exist AES-GCM encrypted inside Legacy + SQLite and therefore backups (`localControlRepository.ts:900-977`). These + must `migrate` to Keychain before claiming no provider secret in SQLite or + backup for migrated users. +4. The v13 retention activation gate reports `activation_gate_closed`; current + legal-source retention cannot be promoted as fully active until physical + cleanup and every model/export boundary is complete. + +## 11. P0/P1 and packaging gates + +Current scripts provide: + +- backend build and `test:workspace:p0-client` (migrations, application/auth, + runtime, source/OCR/Studio, legal sources, credentials/model, Assistant, + workflows and tabular); +- backend `test:workspace:p1-convergence`, SQLCipher, encryption and backup; +- frontend lint/build and `test:p0-client` (Assistant, workflows, tabular, + settings, legal sources, Studio, OCR, shell/i18n/source provenance); +- desktop `test:p0-source`, SQLCipher runtime, desktop migration, packaged + workspace/backup/restore/OCR/smoke and signing readiness; +- full mac packaging orchestration in `scripts/package-desktop-mac.sh`. + +Audit caveats: + +- `desktop test:packaged-p1-convergence` is currently only an alias for native + OCR; it is not a complete P1 aggregate. +- Packaged evidence recorded on 2026-07-15 is historical evidence, not a fresh + run on this branch. +- Desktop credential-backend integration requires a completed backend build. + Running it concurrently while build deletes/recreates `dist` will fail its + preflight; ordered execution is mandatory. + +### Baseline commands run for this audit + +The following passed on the audited baseline after installing locked +dependencies: + +```text +backend: npm run build +backend: npm run test:workspace:p0-client +backend: npm run test:workspace:p1-convergence +backend: npm run test:aletheia:sqlcipher +backend: npm run test:aletheia:encryption +backend: npm run test:desktop-backup +frontend: npm run lint +frontend: npm run build +frontend: npm run test:p0-client +desktop: npm run test:p0-source +``` + +The first desktop aggregate attempt overlapped the backend build's deliberate +`dist` cleanup and failed only the “build backend/dist first” preflight. The +ordered rerun passed, including real macOS Keychain and Electron credential +worker/backend integration. This is a test orchestration dependency, not a +product regression. + +## 12. Mike provenance and license + +Confirmed locally: + +```text +remote: upstream-mike https://github.com/Open-Legal-Products/mike.git +commit: e32daad5a4c64a5561e04c53ee12411e3c5e7238 +license: AGPL-3.0-only +``` + +The fixed commit exists in the Git object database. There is no nested Mike +application. `docs/mike_port_manifest.md:13-36` records the only approved +source, fixed-SHA rule, direct/adapt/rewrite/exclude policy, attribution, brand +boundary, and Legacy-retention rule. Activity files contain per-file SHA/path +comments and source tests compare selected ports to that exact commit. + +Classification: existing controlled ports are `reuse`; a floating upstream +copy, a second Mike frontend, removal of headers/notice, or cloud Supabase/R2/ +organization/share/OAuth/MCP paths are `do-not-use`. + +At baseline, the root had no `THIRD_PARTY_NOTICES.md`; only +`docs/third_party_notices.md`, whose wording was still Aletheia-centric. Phase 0 +adds the root notice and a convergence inventory without removing the existing +attribution. + +## 13. Paths and names that differ from the proposed plan + +| Planned/assumed item | Actual baseline | Decision | +| --- | --- | --- | +| Workspace may still be near v8 | Workspace is v14 | next migration v15; never alter v1-v14. | +| `word-addin/` to be created later | `office-addin/word-manifest.xml` and `/office/word` already exist as Legacy/Hermes POC | `isolate`, then `migrate` in the Word phase; do not create a second simultaneous add-in. | +| Voice might be absent | Python `backend/voice_sidecar/aletheia_voice_sidecar.py` and full Legacy voice runtime exist | `isolate`; no claim of target Conversations/capture runtime. | +| Legacy runtime hidden by product rename | UI hidden from main nav, but routes/runtime/DB side effects remain active | Phase 1 double gate. | +| Source model may need creation | v11 immutable Snapshot/Anchor and v13 retention already exist | `reuse` and extend. | +| Studio may be basic import/export | v12/v14 CAS versions, citations, DOCX and exact-splice suggestions are implemented | `reuse`; unified Review delegates. | +| Origin repository named Vera | configured `origin` URL is still `shawliu998/Aletheia.git`; Git remote main resolves to audited SHA | naming/remote cleanup is separate governance work, not silently changed here. | +| Backend already modular | one large `WorkspaceRuntime` and `veraApplication.ts` composition remain | incremental `adapt`, no stack rewrite. | +| README describes only the active product | README begins with the Mike-derived Vera client but later restores the old Aletheia product claim and Docker `/aletheia` quick start | `adapt` in the final documentation phase; current Docker path is Legacy, not an active-product quick start. | + +The Legacy Docker configuration also passes `ALETHEIA_BACKEND_HOST=0.0.0.0`, +while the current Vera backend rejects every non-`127.0.0.1` bind. It must not +be presented as a verified current desktop or convergence startup path. Docker +compatibility, if retained, needs a separately reviewed loopback/container +network design; weakening the backend loopback rule is `do-not-use`. + +The latest commits also added controlled legal-source adapters and expanded P1 +Studio/source gates. Their closed activation state and credential boundaries +must be preserved; fixtures are not live provider success. + +## 14. Consolidated disposition + +### `reuse` + +- Electron/Next/Express/TypeScript/SQLCipher/encrypted blobs/Keychain stack; +- Project technical ownership and existing typed `/api/v1` clients; +- durable jobs/SSE and bounded workflow step model; +- source snapshots, citation anchors, source viewer/resolver; +- Studio versions/CAS/suggestions/DOCX; +- Assistant, workflow and tabular execution; +- backup/restore, loopback, bearer, renderer sandbox and secret redaction; +- Mike fixed-SHA ports and provenance tests. + +### `adapt` + +- application composition into core/workspace/matter/conversation/broker modules; +- Projects presentation and navigation into Matters while retaining Project IDs; +- sources for conversation/email/note locators; +- model profiles with execution/privacy policy; +- Assistant/workflow/tabular output into Proposal and Review; +- health, audit ledger/background fail-closed, retention activation; +- shared modal/source-viewer accessibility and i18n coverage. + +### `migrate` + +- Assistant/Tabular citations into verified Snapshot/Anchor lineage; +- Legacy Matter/Fact/Evidence/Issue/Position/Decision/Review/Task/Voice/Draft; +- Legacy provider secrets from SQLite to Keychain; +- useful Legacy eval history into the converged eval schema. + +### `isolate` + +- all `/aletheia/*` routes and Legacy durable/model/voice/local-control runtime; +- Legacy frontend deep links; +- old global Tabular primary-nav role; +- existing Office/Hermes Add-in proof-of-concept; +- Legacy voice sidecar until the Conversations/capture phases. + +### `delete-later` + +- unreachable Legacy UI, routers, global runtime objects, sidecar/package + requirements, demo scripts and old product docs, only after migration, + backup/restore and replacement fixtures pass. + +### `do-not-use` + +- Legacy tables/routes as a new-module dependency; +- Chat history, summaries, or Tabular cells as formal Matter memory; +- weak citation wires as accepted Artifact provenance; +- arbitrary shell/code/path/URL/network/MCP tools; +- a second frontend, database, document store, settings store, Matter app, or + Word repository; +- graph database migration, Tauri rewrite, production demo seed, or provider + fixtures presented as live integrations; +- unused hard-coded `RelevantQuotes` UI as the new citation system. + +## 15. Immediate Phase 1 acceptance boundary + +Before any Matter migration or UI work: + +1. add strict `VERA_ENABLE_LEGACY_ROUTES` and + `VERA_ENABLE_LEGACY_RUNTIME` flags, default false; +2. avoid loading/constructing Legacy routers and model/voice/durable/demo modules + when disabled, not merely reject inside handlers; +3. make the production desktop pass both flags explicitly as false by default; +4. report Workspace, Matter, Conversation, and Legacy status separately in + health while preserving the existing Workspace health contract; +5. prove default `/aletheia/*` 404, explicit test opt-in, no Legacy durable start, + no demo seed, and correct shutdown; +6. keep all Legacy files/tables/resources intact for migration and regression. + +Audit/background mutation and Legacy-secret migration are recorded security +work for later bounded commits; they must not be falsely declared complete by +the Phase 1 route/runtime gate. diff --git a/docs/convergence/data-migration-plan.md b/docs/convergence/data-migration-plan.md new file mode 100644 index 0000000..b9667d6 --- /dev/null +++ b/docs/convergence/data-migration-plan.md @@ -0,0 +1,197 @@ +# Vera Legal Matter Agent Convergence — Data Migration Plan + +Date: 2026-07-16 +Baseline Workspace schema: v14 +Status: Phase 0 migration design; no migration is applied by this document + +## 1. Migration contract + +All new Workspace migrations are additive, ordered, checksum-recorded, +transactional, idempotent, and upgradeable from every runtime-valid production +prefix already supported by the v14 migration runner. Existing migration files +and checksums are immutable. + +There are no destructive down migrations. However, the current runner fails +closed when its binary does not recognize the recorded migration registry, so +an arbitrary older binary cannot simply ignore a newer ledger. Application +rollback must use a compatibility binary that still knows the landed migration +registry, or restore the verified pre-migration encrypted backup. It never drops +new tables or deletes selected migrated records in place. Before Legacy +migration, a verified encrypted backup and migration report are mandatory. + +## 2. Planned schema sequence + +The next migration number is v15. Numbers below are reservations so phases do +not accidentally combine unrelated domains; a number is considered final only +when its migration and checksum land. + +| Version | Domain | Tables / changes | Phase | +| --- | --- | --- | --- | +| v15 | Matter foundation | `matter_profiles`, `matter_policies` | Matter Profile | +| v16 | Artifact graph | `matter_artifacts`, `artifact_revisions`, `artifact_source_links`, `artifact_relations` | Case Map | +| v17 | Human review/work | `review_items`, `review_decisions`, `work_items`, `work_item_links` | Review + Work Queue | +| v18 | Conversations | sessions, participants, transcript segments, speaker bindings, extractions, processing runs; additive Source kind/locator support | Imported Conversations | +| v19 | Email/manual-note sources | `project_source_records`, `project_source_record_versions`; additive email/note snapshot locators | Source extensions | +| v20 | Validation | `validation_runs`, `validation_findings`, `artifact_dependencies`, `stale_markers` | Validation | +| v21 | Agent/inference | agent runs/events/tool calls/approvals/proposals and privacy-profile additions | Agent Broker | +| v22 | Word bridge | `word_document_bindings`, `word_sync_sessions`, `word_operations` | Word | +| v23 | Legacy migration ledger | `legacy_migration_runs`, `legacy_migration_items`, `legacy_migration_errors` | Legacy migration | +| v24 | Evaluation | `eval_cases`, `eval_runs`, `eval_annotations` | Evaluation/release quality | + +If implementation reveals a necessary separation, allocate another version; +never append unrelated DDL to an already reviewed migration. + +## 3. Invariants enforced by schema and service + +### MatterProfile + +- `project_id` is both primary/unique ownership and a foreign key to Projects. +- Project deletion follows the existing Project ownership policy. +- nullable legal metadata is not a place for source facts or model conclusions. +- create-Matter service writes Project and MatterProfile in one transaction. + +### Artifact graph + +- Artifact IDs and revisions are Project-scoped through verified ownership. +- `current_revision_id` must point to a revision owned by that Artifact. +- Artifact rows hold stable identity/status; payload lives in immutable revision + JSON with a bounded, named schema. +- revision, source-link, and relation updates are rejected; corrections append. +- source links bind an existing snapshot and optional anchor from the same + Project and store a typed support relation. +- relation endpoints belong to the same Project and reject self/cycle/type + combinations not permitted by the relation registry. + +### Review + +- a Review Item references a target; it does not clone the target payload; +- a target/dedup key prevents duplicate open review work; +- one unresolved item cannot have a terminal decision; +- acceptance/modification/rejection and Artifact revision promotion occur in a + single service transaction with audit protection healthy; +- client instructions and conflicts are marked non-batchable. + +### Work Queue + +- automated sources carry a stable dedup key; +- completion is timestamped and retained; +- source ID, related Artifact, owner, priority, and due date remain separately + queryable; no source payload is copied. + +### Conversations + +- audio blob reference and Source Snapshot belong to the session Project; +- segment time ranges are non-negative and ordered; +- machine text is immutable; reviewed text is a separate nullable field or + revision record and never replaces machine text; +- extraction references one or more session segments and exactly one Review + Item before promotion; +- speaker confirmation is explicit; customer instructions cannot be accepted + without the required confirmation checks. + +## 4. Source model evolution + +The current source foundation is extended rather than replaced. Existing +Project-document and legal-authority snapshots remain byte-for-byte readable. +New `source_kind` values and strict locator schemas are additive. + +Conversation locator: + +```json +{ + "sessionId": "...", + "segmentId": "...", + "startMs": 0, + "endMs": 1200, + "participantId": null, + "transcriptRevision": "machine:1" +} +``` + +Email, authority, and note locators are similarly strict, bounded, and free of +credentials or local paths. Anchor validation includes quote or segment hash. +A changed transcript/document creates a new snapshot; it does not update the +old source. + +An email or manual note also needs a canonical original. v19 therefore owns a +Project-scoped source record and immutable version in the existing SQLCipher +database, with content in the existing encrypted blob store. Email import +preserves the original message bytes plus normalized body ranges; manual-note +edits append versions. The snapshot capture API binds one exact record version. +There is no arbitrary mailbox connector in this migration: external provider +access requires a later allowlisted credential/egress adapter, and fixture or +link-only metadata cannot stand in for captured content. + +## 5. Legacy mapping + +| Legacy object | New target | Conflict behavior | +| --- | --- | --- | +| Matter | Project + MatterProfile | Preserve Legacy ID; do not merge by name. | +| Fact | `fact` Artifact + migration revision | Preserve allegation/confirmation ambiguity in payload and report. | +| Evidence | `evidence_item` Artifact + source link | Missing source/anchor is reported; never fabricate one. | +| Issue | `issue` Artifact | Preserve status and original text; unknown status is explicit. | +| Position | `position` Artifact/revisions | Order by verified timestamps only; ambiguous order is reported. | +| Decision/Review | decision Artifact or review history | No automatic acceptance without a provable Legacy decision. | +| Task/Deadline | Work Item | Preserve due date and completion history; rule provenance stays linked. | +| Voice | Conversation session | Missing audio/transcript lineage is reported; no synthetic source. | +| Draft | Studio document/version | Import immutable bytes/text and citations when verifiable. | + +Every created object stores its Legacy type and ID in bounded migration +metadata or the migration-item ledger. The unique key is migration source + +Legacy type + Legacy ID + target type, making reruns idempotent. + +## 6. Legacy migration lifecycle + +```text +preflight + -> encrypted backup + restore verification + -> dry-run scan + -> deterministic mapping report + -> user-visible conflicts/unmigratable items + -> execute in bounded batches + -> per-item verification + -> compatibility and isolation report + -> Legacy read-only mode +``` + +Run states are `planned`, `dry_run_complete`, `running`, `complete`, `partial`, +and `failed`. Item states distinguish `mapped`, `created`, `already_migrated`, +`conflict`, `unmigratable`, and `failed`. Safe error records contain object IDs +and normalized reasons, not secrets, absolute paths, raw audio, or unrestricted +document content. + +Failure of one bounded item is recorded without guessing. Transaction scope is +small enough to resume but large enough that a target object and all mandatory +links cannot become partially visible. + +## 7. Upgrade tests per migration + +Each migration adds: + +- clean SQLCipher install and v14-to-current upgrade; +- upgrade from all runtime-valid historical prefixes through the default chain; +- migration checksum and idempotent rerun; +- DDL plus ledger rollback on injected failure; +- foreign-key, CHECK, trigger, and immutable-row tests; +- malformed/boundary JSON and cross-Project isolation tests; +- existing Project/document/source/Studio data preservation; +- application restart and backup/restore verification; +- plaintext/SQLCipher downgrade fail-closed tests. + +Legacy migration additionally uses a real old-schema fixture, dry run, repeated +execution, partial failure, restore, and read-only enforcement. Fixtures are +test evidence only and must never be described as a live provider or real user +migration. + +## 8. Rollback and recovery + +New background jobs are disabled before rollback. A compatibility binary that +contains the new migration registry can run against the additive schema; a +pre-registry binary cannot and requires restoration of the verified encrypted +pre-migration backup. If a new migration fails, its transaction and ledger row +roll back together. If Legacy object migration is partial, rerun from the +recorded item state; do not delete successful new objects. + +Restoring the pre-migration encrypted backup is an explicit whole-workspace +recovery path and is verified before destructive Legacy deletion is ever +considered. No early phase drops Legacy tables, blobs, or source files. diff --git a/docs/convergence/open-source-reuse-plan.md b/docs/convergence/open-source-reuse-plan.md new file mode 100644 index 0000000..d771c36 --- /dev/null +++ b/docs/convergence/open-source-reuse-plan.md @@ -0,0 +1,109 @@ +# Vera Legal Matter Agent Convergence — Open-Source Reuse Plan + +Date: 2026-07-16 +Status: Phase 0 approval policy + +## 1. Rule + +No candidate repository, file, dependency, model, weight, dataset, icon, text, +or visual asset enters active Vera code until its exact source, fixed commit or +version, license, copyright notice, dependency impact, and intended modification +are recorded under `docs/provenance/` and reflected in +`THIRD_PARTY_NOTICES.md` when required. + +Product ideas may be studied without copying closed-source implementation, +branding, icons, copy, screenshots, or proprietary visual assets. + +## 2. Approved current source reuse + +| Source | Pin | License | Status | Scope | +| --- | --- | --- | --- | --- | +| Open Legal Products Mike | `e32daad5a4c64a5561e04c53ee12411e3c5e7238` | AGPL-3.0-only | `reuse` / approved | Controlled UI, wire-shape, and workflow ports listed in `docs/mike_port_manifest.md`; per-file provenance comments retained. | +| `@signalapp/sqlcipher` | npm 3.3.9 | AGPL-3.0-only | `reuse` / approved | SQLCipher Node binding; package license distributed with installed dependency. | + +Mike remains pinned. New Mike code is read only from a reviewed fixed SHA, never +floating `main`, and receives a manifest entry before landing. + +## 3. Candidate research matrix + +These entries are not approved for copying. Until a file-and-model review is +recorded, their status is `do-not-use` for source import and `research-only` for +architecture study. + +| Candidate | Possible bounded use | Required review before approval | +| --- | --- | --- | +| Meetily | Rust device/system-audio capture, recovery, model loading | Repository and file license, transitive crates, platform capture APIs, notices, fixed commit. Do not copy its UI, database, summarizer, or updater. | +| June | saved-audio-first, dual-source recording, crash recovery patterns | Exact source repository/edition, license, third-party capture code, model terms, fixed commit. Pattern study does not authorize code copying. | +| FunASR | Chinese ASR, VAD, punctuation, hotwords, speaker models | Toolkit license plus every selected model/weight/dataset license and redistribution terms. | +| WhisperX | alignment and word timestamps | Code pin/license, dependencies, selected Whisper/alignment model licenses, redistribution and platform packaging. | +| pyannote | diarization | Code license is insufficient: model access terms, gated weights, training data restrictions, and redistribution must be reviewed separately. | +| LangExtract | structured extraction and source alignment | Pin, license, dependencies, attribution, and whether its source-location model fits immutable Vera anchors. | +| Graphiti | temporal/supersession design | Design reference only for MVP. No server or graph database is introduced. Any future code reuse needs separate approval. | +| Vexa | meeting-to-knowledge compilation pattern | Product/architecture study only pending exact repository/file/license review. | + +Screenpipe or other source-available/commercial code is `do-not-use` absent +written authorization that identifies the exact version, files, permitted use, +redistribution conditions, and notice obligations. + +## 4. Competitive-product boundary + +Legora, Harvey, and other proprietary products are references for information +architecture and workflow concepts only. Vera does not copy their implementation, +trademarks, icons, product copy, private APIs, screenshots, or proprietary visual +system. Public product behavior can inform an independently designed flow. + +## 5. Reuse approval record + +Before a reuse commit, add a provenance record containing: + +```text +project and canonical repository URL +fixed commit/tag/package version +retrieval date +upstream file paths +license identifier and license-file hash +copyright headers/notices +selected Vera destination files +why reuse is preferable +excluded upstream subsystems +local modifications +transitive native/runtime dependencies +model/weight/dataset license review, if applicable +security review and test evidence +``` + +Copied files retain copyright headers. Required license text and notices ship in +source and packaged distributions. Unknown or conflicting license terms block +the import; “research use” is not treated as a waiver. + +## 6. Voice-specific gate + +Capture code and AI models are separate approvals. A permissively licensed ASR +toolkit does not establish that a model weight or its training/redistribution +terms are usable. The capture-runtime phase must therefore land in this order: + +1. provenance and license decision; +2. minimal source import with notices; +3. native build and sandbox review; +4. saved-audio durability/recovery tests; +5. separately approved model adapter; +6. separately documented optional model installation. + +No external project contributes Matter logic, provider credentials, legal +strategy, Artifact/Review state, or a second persistence layer. + +## 7. Word and hosted scripts + +The current Legacy Office.js proof-of-concept loads Microsoft's hosted Office.js +runtime. It is `migrate`, not an approved Word Local Bridge implementation. The +Word phase must review Microsoft Add-in terms, origin/CSP behavior, manifest +requirements, and distribution constraints and then record the exact runtime +dependency. Provider credentials and local paths never enter the Add-in. + +## 8. Inventory maintenance + +`docs/provenance/open-source-inventory.md` is the source-level inventory for +this convergence. npm lockfiles remain the authoritative package/version graph; +package license metadata and distributed license files are reviewed during the +release gate. Any reuse change updates the inventory and root notice in the same +commit as the imported source, never afterward. diff --git a/docs/convergence/target-architecture.md b/docs/convergence/target-architecture.md new file mode 100644 index 0000000..4e705cd --- /dev/null +++ b/docs/convergence/target-architecture.md @@ -0,0 +1,330 @@ +# Vera Legal Matter Agent Convergence — Target Architecture + +Date: 2026-07-16 +Baseline: `main` at `12af6fc53317e96314a980250d3bd12d5bfd3bcb` +Status: Phase 0 architecture decision record + +## 1. Outcome + +Vera converges on one local desktop product and one canonical work loop: + +```text +Source Snapshot + -> bounded AI Proposal + -> Review Item + -> lawyer decision + -> Matter Artifact revision + -> Draft / Task / Decision + -> Audit / Evaluation +``` + +The product is Matter-centric in the user interface while retaining `Project` +as the technical ownership, backup, permission, document, workflow, and +Assistant boundary. Chat and model output are not system memory. Durable legal +state lives in immutable, source-linked Artifact revisions. + +## 2. Architecture decisions + +| Decision | Classification | Consequence | +| --- | --- | --- | +| Keep Electron + Next.js + Express/TypeScript + SQLCipher + encrypted blobs + Keychain + durable SQLite jobs | `reuse` | No Tauri rewrite and no second application runtime. | +| Keep `projects.id` as the ownership key | `reuse` | UI may say Matter; existing tables and foreign keys are not destructively renamed. | +| Add `matter_profiles` one-to-zero-or-one with Project | `adapt` | A generic Project remains valid; a legal Matter is Project plus MatterProfile. | +| Extend Source Snapshot and Citation Anchor | `adapt` | Documents, conversations, email, authority, and notes share one provenance model. | +| Add immutable Artifact revisions and typed relations | `adapt` | Current state is projected from revision history; no in-place legal-state overwrite. | +| Make Review Inbox the only promotion boundary | `adapt` | AI, workflows, OCR, conversations, and Studio produce candidates or suggestions only. | +| Reuse the durable job pump and model gateway | `adapt` | Agent runs and legal workflow steps do not create another scheduler or model setting store. | +| Put all model tools behind a typed local Tool Broker | `adapt` | No arbitrary shell, path, URL, HTTP, MCP, or unapproved external action. | +| Disable Legacy routes/runtime by default, then extract stable capabilities | `isolate` | The active product never depends on a Legacy route or Legacy table. | +| Migrate Legacy records with reports and immutable legacy IDs | `migrate` | Migration is repeatable, conflict-preserving, and never deletes source data. | +| Remove unreachable Legacy UI/runtime only after release gates | `delete-later` | Legacy tables and files are not deleted in early phases. | +| Graph database, second frontend/database/document store, arbitrary agent execution | `do-not-use` | Temporal state stays in SQLCipher and bounded application services. | + +## 3. Runtime topology + +There remains one composition root and one loopback HTTP server. The current +large Workspace runtime is split behind module factories without changing the +desktop process topology: + +```text +Electron main + |-- credential utility process <-> macOS Keychain + |-- Vera Express backend (127.0.0.1 only) + | |-- core + | |-- workspace + | |-- matter + | |-- conversations + | `-- brokers + `-- Next.js renderer (sandboxed) + +Optional later process: + capture-runtime Rust sidecar + - audio devices, durable WAV capture, VAD, recovery, ASR adapter events + - no Matter, credential, Artifact, Review, or document knowledge +``` + +The target composition shape is: + +```ts +const core = createCoreModule(); +const workspace = createWorkspaceModule(core); +const matter = createMatterModule(core, workspace); +const conversations = createConversationModule(core, workspace, matter); +const brokers = createBrokerModule(core, workspace, matter, conversations); + +return createVeraRuntime({ + core, + workspace, + matter, + conversations, + brokers, +}); +``` + +Module factories own construction and lifecycle. Routes depend on narrow ports; +repositories do not call routes; new modules never import from +`lib/aletheia`, `/aletheia/*`, or Legacy database tables. + +## 4. Module ownership + +### Core — `reuse` then `adapt` + +Owns loopback authentication, SQLCipher connection and migrations, encrypted +blob storage, Keychain credential ports, durable jobs, audit protection, safe +errors, backup/restore, and lifecycle. Existing fail-closed behavior remains a +startup and mutation precondition. + +### Workspace — `reuse` + +Owns Projects, documents and immutable versions, imported email/manual-note +source records and versions, source snapshots, citation anchors, Assistant +messages and durable generation, workflows, tabular review, Document Studio, +and model settings. It remains usable for a Project without a MatterProfile. + +### Matter — new bounded module, `adapt` + +Owns MatterProfile, Artifact identities and revisions, relations, Review Inbox, +Work Queue, validation/stale state, and activity projections. It references +Workspace source/document identities; it does not copy source content. + +### Evaluation — new bounded service under Matter, `adapt` + +Owns `eval_cases`, `eval_runs`, and lawyer annotations derived from reviewed +outcomes. It measures extraction, grounding, speaker attribution, dates/amounts, +unsupported claims, review acceptance/modification, stale detection, workflow +completion, and Word operation failures by Matter type, model, and execution +location. Corrections become reviewable evaluation data, never automatic model +learning or silent policy changes. + +### Conversations — new bounded module, `adapt` + +Owns imported or captured conversation sessions, participants, machine and +reviewed transcript layers, speaker binding, processing runs, and candidate +extractions. An extraction always references transcript segments and creates a +Review Item before it can become a Matter Artifact. + +### Brokers — extracted boundary, `adapt` + +Owns inference policy, typed tools, legal-source adapters, Word Local Bridge, +and approval checks. The broker calculates egress from Matter Policy, Source +Policy, Model Privacy Profile, and current approval state at the last possible +boundary. + +### Legacy Aletheia — `isolate`, then `migrate`, then `delete-later` + +Legacy can be explicitly enabled for tests and migration tools. Active modules +may reuse an extracted core capability, but may not call Legacy routes, +repositories, global schedulers, or tables. Legacy adapters may call new core +modules during the compatibility window; dependency direction never reverses. + +## 5. Canonical domain rules + +1. Every legal Artifact has a stable Project-scoped identity and immutable + revisions. +2. `candidate`, `accepted`, `superseded`, and `rejected` describe legal-state + disposition; they are not inferred from model confidence. +3. AI-created revisions use `createdByType` and lineage. Acceptance or lawyer + modification creates a new revision in the same transaction as the Review + decision. +4. Source links attach to a specific Artifact revision, not merely the Artifact + identity. +5. A Source Snapshot is immutable. A Citation Anchor is validated against the + exact snapshot content, quote/segment hash, and locator revision. +6. Source changes invalidate anchors and can mark dependent outputs stale; they + never mutate the original snapshot. +7. Customer instructions require confirmed speaker identity, an explicit + Review Item, and a single-item decision. They are never bulk accepted. +8. Completed Work Items, superseded positions, prior drafts, rejected + proposals, and migration errors remain queryable. + +## 6. API boundaries + +All active APIs remain under the sole authenticated `/api/v1` composition +root. Additive resource families are introduced in this order: + +```text +/api/v1/matters +/api/v1/projects/:projectId/matter-profile +/api/v1/projects/:projectId/artifacts +/api/v1/projects/:projectId/reviews +/api/v1/work-items +/api/v1/projects/:projectId/conversations +/api/v1/projects/:projectId/source-records +/api/v1/agent-runs +/api/v1/word-bridge +/api/v1/evaluations +``` + +Compatibility requirements: + +- existing `/api/v1/projects`, document, Assistant, workflow, tabular, source, + and Studio contracts remain valid; +- new Matter creation atomically creates a Project and MatterProfile; +- a generic Project is not silently converted into a Matter; +- `/aletheia/*` is not mounted unless `VERA_ENABLE_LEGACY_ROUTES=true`; +- Legacy runtime startup is independent and requires + `VERA_ENABLE_LEGACY_RUNTIME=true`; +- health reports Workspace, Matter, Conversation, and Legacy lifecycle states + separately without paths, secrets, raw errors, or source content. + +## 7. Review and transaction boundaries + +Proposal creation and acceptance are separate operations: + +```text +proposal transaction: + candidate Artifact / external target reference + + Review Item + + audit event + +accept transaction: + Review Decision + + accepted or lawyer-modified Artifact revision + + current revision pointer update + + relation/source links + + optional deduplicated Work Item + + audit event +``` + +Studio acceptance continues to use current base-version compare-and-swap, +exact-splice validation, and immutable versions. Review Inbox stores a target +reference and review metadata; it does not duplicate Studio, OCR, workflow, +tabular, or transcript payloads. + +## 8. Agent and workflow boundary + +An Agent Run pins its model profile and allowed tool set when planned. Model +changes apply only to a later run. Events and tool calls are append-only and +recoverable after restart. + +The Tool Broker accepts strict typed identifiers, never raw paths or arbitrary +URLs. Mutating tools create proposals. External reads pass allowlist, +credential, retention, and egress checks. The model cannot manufacture an +approval or accepted state. + +Legal workflow definitions declare input/output types, allowed tools, required +capabilities, remote-inference policy, human-review requirement, stale +conditions, and recovery behavior. Workflow execution continues through the +existing durable job system. + +## 9. Information architecture + +Top-level active navigation converges to: + +```text +Matters | Work Queue | Workflows | Assistant | Settings +``` + +Matter navigation is: + +```text +Overview | Sources | Case Map | Work | Activity +``` + +Tabular Review remains reachable by compatible deep links but is presented as a +Matter Work capability. Global Assistant is for unassigned work; Matter +Assistant binds the current Project by default. Existing source viewers, +citation controls, document preview, rich-text editor, and Studio are reused. +All new strings enter the existing i18n mechanism; conflict, confidence, and +review state are conveyed by text/icon as well as color. + +## 10. Conversation architecture + +Phase 5 starts with imported WAV, MP3, M4A, and MP4. The invariant is: + +```text +saved audio = source of truth +live transcript = ephemeral preview +batch transcript = persisted machine layer +reviewed transcript = separate human layer +``` + +Audio becomes an encrypted blob governed by Matter retention policy. Machine +text is never overwritten by reviewed text. Conversation snapshots and anchors +use segment/time locators and bind the transcript revision. Extraction produces +candidates only. + +Real-time capture is a later, independently packaged sidecar. `.partial` files, +atomic rename, bytes-on-disk recovery, explicit deletion audit, and +re-transcription from saved audio are release gates. + +## 10.1 Email and manual-note sources + +Email and notes are owned by Workspace Sources, not Conversations and not a new +document repository. A Project-scoped source record has immutable versions; +original message bytes or versioned note text use the existing encrypted blob +store. Snapshot capture binds one exact version, and anchors use message/thread +body ranges or note character ranges plus hashes. The first delivery is bounded +local import/manual editing. Provider mailbox access remains unavailable until +an allowlisted adapter passes credential, retention and egress review. + +## 11. Word architecture + +The existing `office-addin/` and `/office/word` proof-of-concept are +`migrate` assets, not the target security boundary. They are replaced or moved +to `word-addin/` only in the dedicated Word phase. + +Document Studio remains canonical. Word is an external editing surface paired +through a loopback Vera Word Local Bridge using one-time pairing codes and +short-lived, Project-and-document-session scoped tokens. The bridge accepts no +provider credential, arbitrary path, or arbitrary URL. Conflicts preserve both +versions and create a new Studio version; whole-document model overwrite is not +allowed. + +## 12. Security invariants + +- backend and Word Bridge bind validated loopback literals only; +- renderer stays sandboxed and receives neither provider secrets nor local + absolute paths; +- SQLCipher, encrypted blobs, Keychain, per-launch bearer, and backup/restore + fail-closed behavior remain mandatory; +- audit-health failure blocks mutations; +- Matter isolation is checked in repositories and services, not only routes; +- source retention/egress policy is re-evaluated at model, export, Word, and + external-source boundaries; +- logs and audit events exclude secrets, raw audio, unlimited full text, + absolute paths, and unredacted provider failures; +- high-risk external actions require a recorded approval; court filing, final + email sending, and automatic client-instruction acceptance remain unavailable. + +## 13. Delivery order and commit boundaries + +Each phase must leave the desktop startable. Within a phase, schema, backend, +UI, voice, Word, and Legacy deletion are separate commits when more than one is +present. The ordered delivery is: + +1. audit/design documentation; +2. Legacy route/runtime isolation; +3. MatterProfile persistence/API, then Matters naming/navigation; +4. Artifact/revision graph, then Case Map and Assistant proposal entry; +5. Review Inbox, then Work Queue and integrations; +6. imported Conversations; +7. real-time capture sidecar; +8. inference/tool brokers and legal workflow steps; +9. email/manual-note source records and capture APIs in a separate schema/backend commit; +10. Word Local Bridge and Add-in; +11. Legacy data migration and code isolation; +12. evaluation storage/readouts and packaged release/security gates. + +No phase claims completion with static UI, TODO handlers, fixtures standing in +for providers, or a success response that did not perform the bounded action. diff --git a/docs/provenance/open-source-inventory.md b/docs/provenance/open-source-inventory.md new file mode 100644 index 0000000..49c7316 --- /dev/null +++ b/docs/provenance/open-source-inventory.md @@ -0,0 +1,22 @@ +# Open-Source Inventory + +Date: 2026-07-16 +Scope: source-level and runtime integrations relevant to legal-agent convergence + +| Component | Source / version | License status | Embedded? | Classification | Notes | +| --- | --- | --- | --- | --- | --- | +| Vera repository | current repository at `12af6fc5` baseline | AGPL-3.0-only | Yes | `reuse` | Root `LICENSE` retained. | +| Open Legal Products Mike | `https://github.com/Open-Legal-Products/mike`, commit `e32daad5a4c64a5561e04c53ee12411e3c5e7238` | AGPL-3.0-only | Controlled ports | `reuse` | Exact port rules and paths are in `docs/mike_port_manifest.md`; source object and `upstream-mike` remote are present. | +| `@signalapp/sqlcipher` | npm 3.3.9 | AGPL-3.0-only per installed package metadata | Dependency | `reuse` | Provides the SQLCipher binding; linked cipher/runtime attestation remains a release gate. | +| Electron | npm 39.8.10 | MIT per installed package metadata | Dependency | `reuse` | Desktop runtime; lockfile controls exact dependency graph. | +| Next.js | npm 16.2.6 | MIT per installed package metadata | Dependency | `reuse` | Renderer/runtime framework; lockfile controls exact dependency graph. | +| Apple Vision OCR | macOS platform framework | Platform SDK terms | No copied third-party source | `reuse` | Vera-owned Swift adapter uses the operating-system framework. | +| Office.js | Microsoft hosted runtime referenced by Legacy `/office/word` | Terms review required for target distribution | Not vendored | `migrate` | Existing proof-of-concept is not the target loopback pairing/bridge. Review and record terms in the Word phase. | +| faster-whisper | Optional user/operator Python environment | Toolkit and selected model terms not yet recorded for redistribution | Not bundled by current desktop package | `isolate` | Legacy sidecar checks for an external installation. New Conversations cannot claim availability until code and model licenses are independently approved. | +| Meetily, June, FunASR, WhisperX, pyannote, LangExtract, Graphiti, Vexa | Candidate projects only | Not yet pinned/reviewed | No | `do-not-use` | Research-only until an exact provenance and license record is approved. | +| Screenpipe and unknown-license/source-available candidates | No approved source | Not approved | No | `do-not-use` | Requires explicit written authorization identifying exact scope before any import. | + +No candidate voice, graph, temporal-memory, or meeting product source was copied +as part of Phase 0. npm package lockfiles are the authoritative version graph for +ordinary dependencies; the release gate must retain/distribute dependency +licenses as required. From edf2682739d1506eb1a8b218e134af172237676b Mon Sep 17 00:00:00 2001 From: shawliu998 <260819717+shawliu998@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:59:04 +0800 Subject: [PATCH 02/10] feat: isolate legacy runtime by default --- .env.example | 8 + .../scripts/veraWorkspaceApplicationAudit.ts | 250 ++++++++++++++++-- backend/src/veraApplication.ts | 183 +++++++++---- desktop/main.js | 13 + desktop/scripts/packagedAppSmoke.js | 28 ++ desktop/scripts/packagedBackupBridgeAudit.js | 4 + desktop/scripts/packagedNativeOcrAudit.js | 4 + desktop/scripts/packagedNotificationAudit.js | 2 + .../scripts/packagedRestoreFailClosedAudit.js | 2 + desktop/scripts/packagedWorkspaceE2E.js | 2 + desktop/scripts/productRenameAudit.js | 34 +++ desktop/scripts/runtimeSecurityAudit.js | 61 +++++ desktop/scripts/sqlcipherUtilityAudit.js | 4 + docker-compose.yml | 2 + docs/convergence/phase-1-legacy-isolation.md | 119 +++++++++ 15 files changed, 644 insertions(+), 72 deletions(-) create mode 100644 docs/convergence/phase-1-legacy-isolation.md diff --git a/.env.example b/.env.example index dd259c0..4fd0e8f 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,14 @@ ALETHEIA_BACKEND_HOST=0.0.0.0 FRONTEND_URL=http://localhost:3000 NEXT_PUBLIC_API_BASE_URL=http://localhost:3001 +# Legacy Aletheia is retained only for explicit compatibility and migration +# work. The active Vera runtime keeps both surfaces off by default; only the +# exact lowercase value "true" enables a gate. Enabling routes can initialize +# Legacy route-owned database/runtime objects, so do not enable either flag in a +# normal Vera deployment. +VERA_ENABLE_LEGACY_ROUTES=false +VERA_ENABLE_LEGACY_RUNTIME=false + # Local Aletheia auth. single_user is easiest for a local workstation. ALETHEIA_AUTH_MODE=single_user ALETHEIA_LOCAL_USER_ID=local-user diff --git a/backend/src/scripts/veraWorkspaceApplicationAudit.ts b/backend/src/scripts/veraWorkspaceApplicationAudit.ts index eed21f0..0974079 100644 --- a/backend/src/scripts/veraWorkspaceApplicationAudit.ts +++ b/backend/src/scripts/veraWorkspaceApplicationAudit.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import type { Server } from "node:http"; import { resolve } from "node:path"; -import type { Express } from "express"; +import { Router, type Express } from "express"; import type { MikeWorkflowWire } from "../lib/workspace/workflowCompatibility"; import { WorkspaceApiError } from "../lib/workspace/errors"; @@ -433,6 +433,7 @@ function assertWorkspaceNoStoreAbsent(response: Response) { async function auditApplicationSurface(): Promise { let listProjectCalls = 0; + let disabledLegacyFactoryCalls = 0; const runtime = fakeRuntime({ onListProjects: () => { listProjectCalls += 1; @@ -449,6 +450,10 @@ async function auditApplicationSurface(): Promise { last_error: "/Users/private/workspace.db", token: "secret-token", }), + legacyRouterFactory: () => { + disabledLegacyFactoryCalls += 1; + throw new Error("disabled Legacy router factory must not run"); + }, }); await withHttpServer(app, async (baseUrl) => { @@ -479,11 +484,31 @@ async function auditApplicationSurface(): Promise { tabularCell: boolean; }; }; + matter: { status: string }; + conversation: { status: string }; + legacy: { + status: string; + routesEnabled: boolean; + runtimeEnabled: boolean; + }; }; }; assert.equal(healthBody.vera.workspace.pump.documentParse, true); assert.equal(healthBody.vera.workspace.pump.assistantGenerate, false); assert.equal(healthBody.vera.workspace.pump.tabularCell, false); + assert.deepEqual(healthBody.vera.matter, { status: "not_configured" }); + assert.deepEqual(healthBody.vera.conversation, { + status: "not_configured", + }); + assert.deepEqual(healthBody.vera.legacy, { + status: "disabled", + routesEnabled: false, + runtimeEnabled: false, + }); + + const disabledLegacy = await fetch(`${baseUrl}/aletheia/security-policy`); + assert.equal(disabledLegacy.status, 404); + assert.equal(disabledLegacyFactoryCalls, 0); const malformed = await fetch(`${baseUrl}/api/v1/projects`, { method: "POST", @@ -503,6 +528,66 @@ async function auditApplicationSurface(): Promise { }); }); + let enabledLegacyFactoryCalls = 0; + const legacyProbeRouter = Router(); + legacyProbeRouter.get("/enabled-probe", (_request, response) => { + response.json({ enabled: true }); + }); + legacyProbeRouter.post("/guard-audit", (_request, response) => { + response.status(204).end(); + }); + const enabledLegacyApp = createVeraApplication({ + runtime: fakeRuntime(), + env: testEnvironment({ + VERA_ENABLE_LEGACY_ROUTES: "true", + VERA_ENABLE_LEGACY_RUNTIME: "true", + }), + auditAnchorStatus: () => ({ enabled: false, healthy: true }), + legacyRuntimeConfigured: () => true, + legacyRouterFactory: () => { + enabledLegacyFactoryCalls += 1; + return [legacyProbeRouter]; + }, + }); + assert.equal(enabledLegacyFactoryCalls, 1); + await withHttpServer(enabledLegacyApp, async (baseUrl) => { + const probe = await fetch(`${baseUrl}/aletheia/enabled-probe`); + assert.equal(probe.status, 200); + assert.deepEqual(await probe.json(), { enabled: true }); + const health = (await (await fetch(`${baseUrl}/health`)).json()) as { + vera: { + legacy: { + status: string; + routesEnabled: boolean; + runtimeEnabled: boolean; + }; + }; + }; + assert.deepEqual(health.vera.legacy, { + status: "configured", + routesEnabled: true, + runtimeEnabled: true, + }); + }); + + let inexactLegacyFactoryCalls = 0; + const inexactLegacyApp = createVeraApplication({ + runtime: fakeRuntime(), + env: testEnvironment({ VERA_ENABLE_LEGACY_ROUTES: "TRUE" }), + auditAnchorStatus: () => ({ enabled: false, healthy: true }), + legacyRouterFactory: () => { + inexactLegacyFactoryCalls += 1; + return [Router()]; + }, + }); + await withHttpServer(inexactLegacyApp, async (baseUrl) => { + assert.equal( + (await fetch(`${baseUrl}/aletheia/enabled-probe`)).status, + 404, + ); + }); + assert.equal(inexactLegacyFactoryCalls, 0); + const workflowToken = "vera-workflow-http-audit-token-0123456789"; const workflowApp = createVeraApplication({ runtime: fakeRuntime(), @@ -693,7 +778,7 @@ async function auditApplicationSurface(): Promise { auditWriteBlocked: () => true, }); await withHttpServer(blockedApp, async (baseUrl) => { - for (const path of ["/api/v1/projects", "/aletheia/guard-audit"]) { + for (const path of ["/api/v1/projects"]) { const response = await fetch(`${baseUrl}${path}`, { method: "POST", headers: { "content-type": "application/json" }, @@ -711,10 +796,46 @@ async function auditApplicationSurface(): Promise { } const read = await fetch(`${baseUrl}/api/v1/projects`); assert.equal(read.status, 200, "read-only requests remain available"); + const disabledLegacyMutation = await fetch( + `${baseUrl}/aletheia/guard-audit`, + { method: "POST" }, + ); + assert.equal( + disabledLegacyMutation.status, + 404, + "disabled Legacy routes are absent rather than guarded handlers", + ); const health = await fetch(`${baseUrl}/health`); assert.equal(health.status, 503); }); + const blockedLegacyRouter = Router(); + blockedLegacyRouter.get("/guard-audit", (_request, response) => { + response.status(200).json({ readable: true }); + }); + blockedLegacyRouter.post("/guard-audit", (_request, response) => { + response.status(204).end(); + }); + const blockedLegacyApp = createVeraApplication({ + runtime, + env: testEnvironment({ + VERA_ENABLE_LEGACY_ROUTES: "true", + VERA_ENABLE_LEGACY_RUNTIME: "true", + }), + auditAnchorStatus: () => ({ enabled: true, healthy: false }), + auditWriteBlocked: () => true, + legacyRuntimeConfigured: () => true, + legacyRouterFactory: () => [blockedLegacyRouter], + }); + await withHttpServer(blockedLegacyApp, async (baseUrl) => { + const blocked = await fetch(`${baseUrl}/aletheia/guard-audit`, { + method: "POST", + }); + assert.equal(blocked.status, 503); + const read = await fetch(`${baseUrl}/aletheia/guard-audit`); + assert.equal(read.status, 200); + }); + const drainingApp = createVeraApplication({ runtime, env: testEnvironment(), @@ -907,14 +1028,14 @@ async function auditBootstrapFailures(): Promise { }), }), ); - assert.deepEqual(listenEvents.slice(-6), [ + assert.deepEqual(listenEvents.slice(-3), [ "server.close", "runtime.stop", - "durable.close", - "model.close", - "voice.close", "audit.close", ]); + assert(!listenEvents.includes("durable.start")); + assert(!listenEvents.includes("model.close")); + assert(!listenEvents.includes("voice.close")); } async function auditShutdownAndDemo(): Promise { @@ -925,35 +1046,71 @@ async function auditShutdownAndDemo(): Promise { closeTimeoutMs: 10, dependencies: fakeDependencies(events, { server }), }); - assert.deepEqual(events.slice(0, 8), [ + assert.deepEqual(events.slice(0, 7), [ "compliance", "encryption", "auth.preflight", "audit.start", "runtime.create", "runtime.start", - "durable.start", "listen:127.0.0.1:3001", ]); assert(!events.includes("demo.seed"), "demo seed must be off by default"); + assert(!events.includes("durable.start")); const firstShutdown = application.shutdown(); const secondShutdown = application.shutdown(); assert.strictEqual(firstShutdown, secondShutdown, "shutdown is idempotent"); await firstShutdown; - assert.deepEqual(events.slice(-7), [ + assert.deepEqual(events.slice(-4), [ "server.close", "server.closeAllConnections", "runtime.stop", + "audit.close", + ]); + assert(!events.includes("model.close")); + assert(!events.includes("voice.close")); + + const legacyEvents: EventLog = []; + const legacyApplication = await bootstrapVeraApplication({ + env: testEnvironment({ VERA_ENABLE_LEGACY_RUNTIME: "true" }), + dependencies: fakeDependencies(legacyEvents), + }); + assert.deepEqual(legacyEvents.slice(0, 8), [ + "compliance", + "encryption", + "auth.preflight", + "audit.start", + "runtime.create", + "runtime.start", + "durable.start", + "listen:127.0.0.1:3001", + ]); + await legacyApplication.shutdown(); + assert.deepEqual(legacyEvents.slice(-6), [ + "server.close", + "runtime.stop", "durable.close", "model.close", "voice.close", "audit.close", ]); + const gatedDemoEvents: EventLog = []; + const gatedDemoApplication = await bootstrapVeraApplication({ + env: testEnvironment({ ALETHEIA_ENABLE_DEMO_SEED: "true" }), + dependencies: fakeDependencies(gatedDemoEvents), + }); + assert(!gatedDemoEvents.includes("demo.seed")); + assert(!gatedDemoEvents.includes("durable.start")); + await gatedDemoApplication.shutdown(); + const demoEvents: EventLog = []; const demoApplication = await bootstrapVeraApplication({ - env: testEnvironment({ ALETHEIA_ENABLE_DEMO_SEED: "true" }), + env: testEnvironment({ + VERA_ENABLE_LEGACY_RUNTIME: "true", + ALETHEIA_ENABLE_DEMO_SEED: "true", + }), dependencies: fakeDependencies(demoEvents), }); assert(demoEvents.includes("demo.seed")); @@ -967,7 +1124,10 @@ async function auditShutdownAndDemo(): Promise { const failedDemoEvents: EventLog = []; await assert.rejects( bootstrapVeraApplication({ - env: testEnvironment({ ALETHEIA_ENABLE_DEMO_SEED: "true" }), + env: testEnvironment({ + VERA_ENABLE_LEGACY_RUNTIME: "true", + ALETHEIA_ENABLE_DEMO_SEED: "true", + }), dependencies: fakeDependencies(failedDemoEvents, { demo: async () => { failedDemoEvents.push("demo.seed"); @@ -989,6 +1149,7 @@ async function auditShutdownAndDemo(): Promise { const productionApplication = await bootstrapVeraApplication({ env: testEnvironment({ NODE_ENV: "production", + VERA_ENABLE_LEGACY_RUNTIME: "true", ALETHEIA_ENABLE_DEMO_SEED: "true", }), dependencies: fakeDependencies(productionEvents), @@ -1078,21 +1239,60 @@ async function auditStaticOwnership(): Promise { signalCountsBefore, "importing the process entry point must not register signals or bootstrap", ); - for (const legacyRouter of [ - "aletheiaRouter", - "legalResearchRouter", - "legalResearchIssuesRouter", - "legalOpinionsRouter", - "litigationRouter", - "durableAgentRunsRouter", - "localGovernanceRouter", - "localModelsRouter", - "createLocalVoiceRouter()", - "createAletheiaLocalControlRouter()", - ]) { + assert( + applicationSource.includes( + 'return env.VERA_ENABLE_LEGACY_ROUTES === "true";', + ), + ); + assert( + applicationSource.includes( + 'return env.VERA_ENABLE_LEGACY_RUNTIME === "true";', + ), + ); + assert.match( + applicationSource, + /if \(legacyRoutesAreEnabled\) \{[\s\S]*?app\.use\("\/aletheia", mutationGuard\);[\s\S]*?options\.legacyRouterFactory \?\? loadLegacyRouters/, + "Legacy limiters and routers are mounted only inside the explicit route gate", + ); + assert.match( + applicationSource, + /if \(legacyRuntimeIsEnabled\) \{[\s\S]*?dependencies\.configureDurableRuntime\(\);/, + "Legacy runtime configuration is behind the explicit runtime gate", + ); + assert( + applicationSource.includes( + "if (legacyRuntimeIsEnabled && demoSeedEnabled(env))", + ), + ); + + const staticImports = applicationSource.slice( + 0, + applicationSource.indexOf("const LOOPBACK_HOST"), + ); + const lazyLegacyModules = [ + "./routes/aletheia", + "./routes/legalResearch", + "./routes/legalResearchIssues", + "./routes/legalOpinions", + "./routes/litigation", + "./routes/durableAgentRuns", + "./routes/localGovernance", + "./routes/localModels", + "./routes/localVoice", + "./routes/aletheiaLocalControl", + "./lib/aletheia/durableAgentRuntime", + "./lib/aletheia/localModelRuntime", + "./lib/aletheia/localVoiceRuntime", + "./lib/aletheia/demoSeed", + ] as const; + for (const modulePath of lazyLegacyModules) { + assert( + !staticImports.includes(modulePath), + `Legacy module must not be statically imported: ${modulePath}`, + ); assert( - applicationSource.includes(`app.use(\"/aletheia\", ${legacyRouter})`), - `legacy router missing: ${legacyRouter}`, + applicationSource.includes(`require(\"${modulePath}\")`), + `Legacy module must be loaded through a fixed lazy require: ${modulePath}`, ); } } diff --git a/backend/src/veraApplication.ts b/backend/src/veraApplication.ts index f38ce79..3f7d1c0 100644 --- a/backend/src/veraApplication.ts +++ b/backend/src/veraApplication.ts @@ -11,20 +11,6 @@ import express, { import cors from "cors"; import helmet from "helmet"; import rateLimit from "express-rate-limit"; -import { aletheiaRouter } from "./routes/aletheia"; -import { litigationRouter } from "./routes/litigation"; -import { durableAgentRunsRouter } from "./routes/durableAgentRuns"; -import { localGovernanceRouter } from "./routes/localGovernance"; -import { localModelsRouter } from "./routes/localModels"; -import { createLocalVoiceRouter } from "./routes/localVoice"; -import { createAletheiaLocalControlRouter } from "./routes/aletheiaLocalControl"; -import { legalResearchRouter } from "./routes/legalResearch"; -import { legalResearchIssuesRouter } from "./routes/legalResearchIssues"; -import { legalOpinionsRouter } from "./routes/legalOpinions"; -import { seedAletheiaDemoIfNeeded } from "./lib/aletheia/demoSeed"; -import { configureDurableAgentRuntimeFromEnvironment } from "./lib/aletheia/durableAgentRuntime"; -import { closeLocalModelRuntime } from "./lib/aletheia/localModelRuntime"; -import { closeLocalVoiceRuntime } from "./lib/aletheia/localVoiceRuntime"; import { assertLocalEncryptionStartupPolicy } from "./lib/aletheia/localEnvelopeCrypto"; import { assertComplianceDeploymentStartupPolicy } from "./lib/aletheia/localCompliancePreset"; import { @@ -89,6 +75,8 @@ type Closable = { close(): void | Promise; }; +type LegacyRouterFactory = () => readonly Router[]; + export interface VeraWorkspaceRuntime extends WorkspaceV1RuntimePort, @@ -121,6 +109,8 @@ export type VeraApplicationOptions = { auditAnchorStatus?: () => AuditAnchorStatus; auditWriteBlocked?: () => boolean; isDraining?: () => boolean; + legacyRouterFactory?: LegacyRouterFactory; + legacyRuntimeConfigured?: () => boolean; }; export type VeraBootstrapDependencies = { @@ -162,6 +152,79 @@ export class VeraStartupError extends Error { } } +function legacyRoutesEnabled(env: Environment): boolean { + return env.VERA_ENABLE_LEGACY_ROUTES === "true"; +} + +function legacyRuntimeEnabled(env: Environment): boolean { + return env.VERA_ENABLE_LEGACY_RUNTIME === "true"; +} + +/** + * Legacy modules have startup side effects, including local database and voice + * runtime construction. Keep every require inside this explicitly gated + * factory so the default Workspace process never evaluates those modules. + */ +function loadLegacyRouters(): readonly Router[] { + const { aletheiaRouter } = + require("./routes/aletheia") as typeof import("./routes/aletheia"); + const { legalResearchRouter } = + require("./routes/legalResearch") as typeof import("./routes/legalResearch"); + const { legalResearchIssuesRouter } = + require("./routes/legalResearchIssues") as typeof import("./routes/legalResearchIssues"); + const { legalOpinionsRouter } = + require("./routes/legalOpinions") as typeof import("./routes/legalOpinions"); + const { litigationRouter } = + require("./routes/litigation") as typeof import("./routes/litigation"); + const { durableAgentRunsRouter } = + require("./routes/durableAgentRuns") as typeof import("./routes/durableAgentRuns"); + const { localGovernanceRouter } = + require("./routes/localGovernance") as typeof import("./routes/localGovernance"); + const { localModelsRouter } = + require("./routes/localModels") as typeof import("./routes/localModels"); + const { createLocalVoiceRouter } = + require("./routes/localVoice") as typeof import("./routes/localVoice"); + const { createAletheiaLocalControlRouter } = + require("./routes/aletheiaLocalControl") as typeof import("./routes/aletheiaLocalControl"); + + return [ + aletheiaRouter, + legalResearchRouter, + legalResearchIssuesRouter, + legalOpinionsRouter, + litigationRouter, + durableAgentRunsRouter, + localGovernanceRouter, + localModelsRouter, + createLocalVoiceRouter(), + createAletheiaLocalControlRouter(), + ]; +} + +function configureLegacyDurableRuntime(): Closable | null { + const { configureDurableAgentRuntimeFromEnvironment } = + require("./lib/aletheia/durableAgentRuntime") as typeof import("./lib/aletheia/durableAgentRuntime"); + return configureDurableAgentRuntimeFromEnvironment(); +} + +async function closeLegacyLocalModelRuntime(): Promise { + const { closeLocalModelRuntime } = + require("./lib/aletheia/localModelRuntime") as typeof import("./lib/aletheia/localModelRuntime"); + await closeLocalModelRuntime(); +} + +async function closeLegacyLocalVoiceRuntime(): Promise { + const { closeLocalVoiceRuntime } = + require("./lib/aletheia/localVoiceRuntime") as typeof import("./lib/aletheia/localVoiceRuntime"); + await closeLocalVoiceRuntime(); +} + +async function runLegacyDemoSeed(): Promise { + const { seedAletheiaDemoIfNeeded } = + require("./lib/aletheia/demoSeed") as typeof import("./lib/aletheia/demoSeed"); + return seedAletheiaDemoIfNeeded(); +} + function envInt(env: Environment, name: string, fallback: number): number { const raw = env[name]; if (!raw) return fallback; @@ -319,6 +382,8 @@ export function createVeraApplication( const shouldBlockAuditWrites = options.auditWriteBlocked ?? shouldFailClosedForAuditAnchor; const isDraining = options.isDraining ?? (() => false); + const legacyRoutesAreEnabled = legacyRoutesEnabled(env); + const legacyRuntimeIsEnabled = legacyRuntimeEnabled(env); const trustProxyHops = resolveTrustProxyHops(env); const app = express(); @@ -331,14 +396,6 @@ export function createVeraApplication( max: envInt(env, "RATE_LIMIT_UPLOAD_MAX", 50), message: "Too many upload requests. Please try again later.", }); - const externalSourceLimiter = makeLimiter({ - windowMs: minutes( - envInt(env, "RATE_LIMIT_EXTERNAL_SOURCE_WINDOW_MINUTES", 15), - ), - max: envInt(env, "RATE_LIMIT_EXTERNAL_SOURCE_MAX", 20), - message: - "Too many external-source retrieval requests. Please try again later.", - }); const modelProbeLimiter = makeModelProbeLimiter({ windowMs: minutes(envInt(env, "RATE_LIMIT_MODEL_PROBE_WINDOW_MINUTES", 1)), max: envInt(env, "RATE_LIMIT_MODEL_PROBE_MAX", 8), @@ -401,13 +458,23 @@ export function createVeraApplication( next(); }); - app.post( - "/aletheia/matters/:matterId/external-source/fetch", - externalSourceLimiter, - ); - app.post("/aletheia/matters/:matterId/research/*", externalSourceLimiter); - app.post("/aletheia/matters/:matterId/documents", uploadLimiter); - app.post("/aletheia/matters/:matterId/documents/batch", uploadLimiter); + if (legacyRoutesAreEnabled) { + const externalSourceLimiter = makeLimiter({ + windowMs: minutes( + envInt(env, "RATE_LIMIT_EXTERNAL_SOURCE_WINDOW_MINUTES", 15), + ), + max: envInt(env, "RATE_LIMIT_EXTERNAL_SOURCE_MAX", 20), + message: + "Too many external-source retrieval requests. Please try again later.", + }); + app.post( + "/aletheia/matters/:matterId/external-source/fetch", + externalSourceLimiter, + ); + app.post("/aletheia/matters/:matterId/research/*", externalSourceLimiter); + app.post("/aletheia/matters/:matterId/documents", uploadLimiter); + app.post("/aletheia/matters/:matterId/documents/batch", uploadLimiter); + } app.use((request, response, next) => express.json({ limit: env.ALETHEIA_JSON_BODY_LIMIT ?? "5mb" })( @@ -436,17 +503,13 @@ export function createVeraApplication( } next(); }; - app.use("/aletheia", mutationGuard); - app.use("/aletheia", aletheiaRouter); - app.use("/aletheia", legalResearchRouter); - app.use("/aletheia", legalResearchIssuesRouter); - app.use("/aletheia", legalOpinionsRouter); - app.use("/aletheia", litigationRouter); - app.use("/aletheia", durableAgentRunsRouter); - app.use("/aletheia", localGovernanceRouter); - app.use("/aletheia", localModelsRouter); - app.use("/aletheia", createLocalVoiceRouter()); - app.use("/aletheia", createAletheiaLocalControlRouter()); + if (legacyRoutesAreEnabled) { + app.use("/aletheia", mutationGuard); + const legacyRouters = (options.legacyRouterFactory ?? loadLegacyRouters)(); + for (const legacyRouter of legacyRouters) { + app.use("/aletheia", legacyRouter); + } + } // Workspace API composition is intentionally singular: authenticate before // the audit mutation guard, then place the fixed Mike workflow namespace @@ -498,10 +561,24 @@ export function createVeraApplication( ); app.use("/api/v1", workspaceApi); + const legacyHealth = (runtimeConfigured: boolean) => ({ + status: legacyRuntimeIsEnabled + ? runtimeConfigured + ? ("configured" as const) + : ("not_configured" as const) + : legacyRoutesAreEnabled + ? ("routes_only" as const) + : ("disabled" as const), + routesEnabled: legacyRoutesAreEnabled, + runtimeEnabled: legacyRuntimeIsEnabled, + }); + app.get("/health", (_request, response) => { try { const workspace = options.runtime.health(); const audit = getAuditStatus(); + const isLegacyRuntimeConfigured = + legacyRuntimeIsEnabled && options.legacyRuntimeConfigured?.() === true; const draining = isDraining() || workspace.draining; const healthy = workspace.started && !draining && (!audit.enabled || audit.healthy); @@ -522,6 +599,9 @@ export function createVeraApplication( healthy: audit.healthy, protectionActive: audit.protection_active === true, }, + matter: { status: "not_configured" }, + conversation: { status: "not_configured" }, + legacy: legacyHealth(isLegacyRuntimeConfigured), }, }); } catch { @@ -538,6 +618,9 @@ export function createVeraApplication( }, }, audit: { enabled: false, healthy: false, protectionActive: false }, + matter: { status: "not_configured" }, + conversation: { status: "not_configured" }, + legacy: legacyHealth(false), }, }); } @@ -616,10 +699,10 @@ const defaultDependencies: VeraBootstrapDependencies = { auditAnchorStatus: auditAnchorRuntimeStatus, auditWriteBlocked: shouldFailClosedForAuditAnchor, createRuntime: createWorkspaceRuntime, - configureDurableRuntime: configureDurableAgentRuntimeFromEnvironment, - closeLocalModelRuntime, - closeLocalVoiceRuntime, - runDemoSeed: seedAletheiaDemoIfNeeded, + configureDurableRuntime: configureLegacyDurableRuntime, + closeLocalModelRuntime: closeLegacyLocalModelRuntime, + closeLocalVoiceRuntime: closeLegacyLocalVoiceRuntime, + runDemoSeed: runLegacyDemoSeed, listen: defaultListen, }; @@ -670,6 +753,7 @@ export async function bootstrapVeraApplication( options: VeraBootstrapOptions = {}, ): Promise { const env = options.env ?? process.env; + const legacyRuntimeIsEnabled = legacyRuntimeEnabled(env); const binding = resolveVeraBindConfiguration(env, { ...(options.port !== undefined ? { port: options.port } : {}), allowPortZero: options.allowPortZero, @@ -740,8 +824,12 @@ export async function bootstrapVeraApplication( auditAnchor = dependencies.startAuditAnchor(); runtime = dependencies.createRuntime(); await runtime.start(); - legacyRuntimesConfigured = true; - durableRuntime = dependencies.configureDurableRuntime(); + if (legacyRuntimeIsEnabled) { + // Mark configured before invoking the factory so partial Legacy startup + // is still cleaned up if configuration throws after creating a model. + legacyRuntimesConfigured = true; + durableRuntime = dependencies.configureDurableRuntime(); + } const app = createVeraApplication({ runtime, @@ -749,8 +837,9 @@ export async function bootstrapVeraApplication( auditAnchorStatus: dependencies.auditAnchorStatus, auditWriteBlocked: dependencies.auditWriteBlocked, isDraining: () => draining, + legacyRuntimeConfigured: () => legacyRuntimesConfigured, }); - if (demoSeedEnabled(env)) { + if (legacyRuntimeIsEnabled && demoSeedEnabled(env)) { await dependencies.runDemoSeed(); } const handle = dependencies.listen(app, binding.port, binding.host); diff --git a/desktop/main.js b/desktop/main.js index ac10c2e..2e1d7a2 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -265,6 +265,18 @@ function selectedProcessEnvironment(keys) { }, {}); } +function legacyFeatureEnvironment() { + return { + // Legacy surfaces are retained for explicit migration and compatibility + // work, but the formal desktop runtime must never activate them through an + // ambient or loosely parsed parent value. + VERA_ENABLE_LEGACY_ROUTES: + process.env.VERA_ENABLE_LEGACY_ROUTES === "true" ? "true" : "false", + VERA_ENABLE_LEGACY_RUNTIME: + process.env.VERA_ENABLE_LEGACY_RUNTIME === "true" ? "true" : "false", + }; +} + function localDataDir() { return path.join(app.getPath("userData"), "aletheia-data"); } @@ -1150,6 +1162,7 @@ async function startServices() { cwd: backendDir, env: { ...selectedProcessEnvironment(BACKEND_LOCAL_CONFIG_ENV_KEYS), + ...legacyFeatureEnvironment(), ...auditAnchorEnvironment(), NODE_ENV: "production", PORT: String(BACKEND_PORT), diff --git a/desktop/scripts/packagedAppSmoke.js b/desktop/scripts/packagedAppSmoke.js index 7e826bc..3876092 100644 --- a/desktop/scripts/packagedAppSmoke.js +++ b/desktop/scripts/packagedAppSmoke.js @@ -19,6 +19,7 @@ const BACKEND_PORT = Number( ); const FRONTEND_URL = `http://${HOST}:${FRONTEND_PORT}/assistant`; const BACKEND_URL = `http://${HOST}:${BACKEND_PORT}/health`; +const LEGACY_ROUTE_URL = `http://${HOST}:${BACKEND_PORT}/aletheia/local-voice/status`; const STARTUP_TIMEOUT_MS = 180_000; const SHUTDOWN_TIMEOUT_MS = 20_000; const POLL_INTERVAL_MS = 500; @@ -85,6 +86,25 @@ function requestStatus(url) { }); } +async function assertFormalLegacyDefaults() { + const healthResponse = await fetch(BACKEND_URL, { + signal: AbortSignal.timeout(3_000), + }); + assert.equal(healthResponse.status, 200); + const health = await healthResponse.json(); + assert.equal(health?.vera?.legacy?.status, "disabled"); + assert.equal(health?.vera?.legacy?.routesEnabled, false); + assert.equal(health?.vera?.legacy?.runtimeEnabled, false); + + const legacyRoute = await requestStatus(LEGACY_ROUTE_URL); + assert.equal( + legacyRoute.status, + 404, + `formal desktop Legacy route must be absent (error=${legacyRoute.error ?? "none"})`, + ); + return health.vera.legacy; +} + function requireRegularFile(filePath, label, { privateAccess = false } = {}) { const info = fs.statSync(filePath, { throwIfNoEntry: false }); if (!info?.isFile() || info.size <= 0) { @@ -239,6 +259,8 @@ async function assertPackagedEncryptionDowngradeRejected({ env: { ...process.env, VERA_DESKTOP_PROFILE_DIR: userDataDir, + VERA_ENABLE_LEGACY_ROUTES: "false", + VERA_ENABLE_LEGACY_RUNTIME: "false", ALETHEIA_DEMO_SEED_ENABLED: "false", ALETHEIA_REQUIRE_ENCRYPTED_VOLUME: "false", ALETHEIA_APPLICATION_ENCRYPTION: applicationEncryption, @@ -314,6 +336,7 @@ async function main() { let child = null; let failure = null; let isolatedProfile = null; + let legacyHealth = null; let isolatedLogTail = ""; try { @@ -323,6 +346,8 @@ async function main() { env: { ...process.env, VERA_DESKTOP_PROFILE_DIR: userDataDir, + VERA_ENABLE_LEGACY_ROUTES: "false", + VERA_ENABLE_LEGACY_RUNTIME: "false", ALETHEIA_DEMO_SEED_ENABLED: "false", ALETHEIA_REQUIRE_ENCRYPTED_VOLUME: "false", ALETHEIA_APPLICATION_ENCRYPTION: "required", @@ -349,6 +374,7 @@ async function main() { }); await waitForServices(child, processState); + legacyHealth = await assertFormalLegacyDefaults(); isolatedProfile = verifyIsolatedProfile(userDataDir); } catch (error) { failure = error; @@ -384,6 +410,8 @@ async function main() { applicationEncryptionDowngradeRejected: true, databaseEncryptionDowngradeRejected: true, demoSeedDisabled: true, + legacyHealth, + legacyRouteStatus: 404, frontendStatus: 200, backendHealthStatus: 200, isolatedProfile, diff --git a/desktop/scripts/packagedBackupBridgeAudit.js b/desktop/scripts/packagedBackupBridgeAudit.js index ee0e73b..d5dcdb0 100644 --- a/desktop/scripts/packagedBackupBridgeAudit.js +++ b/desktop/scripts/packagedBackupBridgeAudit.js @@ -273,6 +273,10 @@ async function main() { env: { ...process.env, VERA_DESKTOP_PROFILE_DIR: userDataDir, + // This compatibility audit deliberately creates Legacy matters. Keep + // the durable model/voice runtime disabled while opting into routes. + VERA_ENABLE_LEGACY_ROUTES: "true", + VERA_ENABLE_LEGACY_RUNTIME: "false", ALETHEIA_DEMO_SEED_ENABLED: "false", ALETHEIA_REQUIRE_ENCRYPTED_VOLUME: "false", ALETHEIA_APPLICATION_ENCRYPTION: "required", diff --git a/desktop/scripts/packagedNativeOcrAudit.js b/desktop/scripts/packagedNativeOcrAudit.js index d759bc1..0246379 100644 --- a/desktop/scripts/packagedNativeOcrAudit.js +++ b/desktop/scripts/packagedNativeOcrAudit.js @@ -442,6 +442,10 @@ async function main() { const launchEnvironment = { ...process.env, VERA_DESKTOP_PROFILE_DIR: userDataDir, + // Provider-readiness compatibility remains on the retained Legacy route; + // no durable Legacy worker is required by this Project/Studio audit. + VERA_ENABLE_LEGACY_ROUTES: "true", + VERA_ENABLE_LEGACY_RUNTIME: "false", ALETHEIA_DEMO_SEED_ENABLED: "false", ALETHEIA_REQUIRE_ENCRYPTED_VOLUME: "false", ALETHEIA_APPLICATION_ENCRYPTION: "required", diff --git a/desktop/scripts/packagedNotificationAudit.js b/desktop/scripts/packagedNotificationAudit.js index 2c0a5de..c328391 100644 --- a/desktop/scripts/packagedNotificationAudit.js +++ b/desktop/scripts/packagedNotificationAudit.js @@ -34,6 +34,8 @@ async function main() { env: { ...process.env, VERA_DESKTOP_PROFILE_DIR: userDataDir, + VERA_ENABLE_LEGACY_ROUTES: "false", + VERA_ENABLE_LEGACY_RUNTIME: "false", ALETHEIA_DEMO_SEED_ENABLED: "false", ALETHEIA_REQUIRE_ENCRYPTED_VOLUME: "false", ALETHEIA_APPLICATION_ENCRYPTION: "required", diff --git a/desktop/scripts/packagedRestoreFailClosedAudit.js b/desktop/scripts/packagedRestoreFailClosedAudit.js index 2817591..268f135 100644 --- a/desktop/scripts/packagedRestoreFailClosedAudit.js +++ b/desktop/scripts/packagedRestoreFailClosedAudit.js @@ -54,6 +54,8 @@ async function runCase(args) { env: { ...process.env, VERA_DESKTOP_PROFILE_DIR: userDataDir, + VERA_ENABLE_LEGACY_ROUTES: "false", + VERA_ENABLE_LEGACY_RUNTIME: "false", ALETHEIA_DESKTOP_FRONTEND_PORT: String(args.frontendPort), ALETHEIA_DESKTOP_BACKEND_PORT: String(args.backendPort), ALETHEIA_DEMO_SEED_ENABLED: "false", diff --git a/desktop/scripts/packagedWorkspaceE2E.js b/desktop/scripts/packagedWorkspaceE2E.js index 4c46606..d0c14a4 100644 --- a/desktop/scripts/packagedWorkspaceE2E.js +++ b/desktop/scripts/packagedWorkspaceE2E.js @@ -653,6 +653,8 @@ function launchEnvironment(userDataDir, applicationMasterKey, databaseKey) { return { ...process.env, VERA_DESKTOP_PROFILE_DIR: userDataDir, + VERA_ENABLE_LEGACY_ROUTES: "false", + VERA_ENABLE_LEGACY_RUNTIME: "false", ALETHEIA_DEMO_SEED_ENABLED: "false", ALETHEIA_REQUIRE_ENCRYPTED_VOLUME: "false", ALETHEIA_APPLICATION_ENCRYPTION: "required", diff --git a/desktop/scripts/productRenameAudit.js b/desktop/scripts/productRenameAudit.js index ba8e7c0..a5c567f 100644 --- a/desktop/scripts/productRenameAudit.js +++ b/desktop/scripts/productRenameAudit.js @@ -41,6 +41,10 @@ const LEGAL_SOURCE_CONFIG_ENV_KEYS = [ "VERA_OFFICIAL_LEGAL_API_ENDPOINT", "VERA_OFFICIAL_LEGAL_API_ALLOWED_HOSTS", ]; +const LEGACY_FEATURE_ENV_KEYS = [ + "VERA_ENABLE_LEGACY_ROUTES", + "VERA_ENABLE_LEGACY_RUNTIME", +]; function sourceArrayValues(source, declaration) { const declarationMatch = source.match( @@ -71,6 +75,12 @@ function auditLegalSourceConfiguration() { LEGAL_SOURCE_CONFIG_ENV_KEYS.length, "legal-source configuration keys must not be duplicated", ); + for (const key of LEGACY_FEATURE_ENV_KEYS) { + assert.ok( + !backendEnvironmentKeys.includes(key), + `${key} must be normalized explicitly instead of copying its raw parent value`, + ); + } assert.match( mainSource, /selectedProcessEnvironment\(BACKEND_LOCAL_CONFIG_ENV_KEYS\)/, @@ -143,6 +153,27 @@ assert.doesNotMatch( ); assert.ok(fs.existsSync(path.join(desktopDir, "build", "icon.icns"))); assert.ok(fs.existsSync(path.join(desktopDir, "build", "icon.png"))); +assert.match( + mainSource, + /VERA_ENABLE_LEGACY_ROUTES:\s*\n?\s*process\.env\.VERA_ENABLE_LEGACY_ROUTES === "true" \? "true" : "false"/, + "the Vera desktop must keep Legacy routes off unless the parent opts in with exact true", +); +assert.match( + mainSource, + /VERA_ENABLE_LEGACY_RUNTIME:\s*\n?\s*process\.env\.VERA_ENABLE_LEGACY_RUNTIME === "true" \? "true" : "false"/, + "the Vera desktop must keep Legacy runtime off unless the parent opts in with exact true", +); +assert.match( + mainSource, + /\.\.\.legacyFeatureEnvironment\(\),/, + "the formal backend child must receive normalized Legacy feature decisions", +); +assert.ok( + packageDocument.build.extraResources.some( + (entry) => entry.to === "aletheia/backend/voice_sidecar", + ), + "Legacy voice resources remain available for explicit compatibility runs", +); auditLegalSourceConfiguration(); console.log( @@ -157,6 +188,9 @@ console.log( userDataDirectory: "aletheia-desktop", startupPath: "/assistant", legacyRoutesPreserved: true, + legacyRoutesDefaultEnabled: false, + legacyRuntimeDefaultEnabled: false, + legacyOptInValue: "true", legalSourceConfiguration: { forwardedToBackend: LEGAL_SOURCE_CONFIG_ENV_KEYS, composeMapped: true, diff --git a/desktop/scripts/runtimeSecurityAudit.js b/desktop/scripts/runtimeSecurityAudit.js index 1ada3fe..be67726 100644 --- a/desktop/scripts/runtimeSecurityAudit.js +++ b/desktop/scripts/runtimeSecurityAudit.js @@ -14,6 +14,10 @@ const encryptionPolicy = fs.readFileSync( const desktopPackage = JSON.parse( fs.readFileSync(path.join(desktopRoot, "package.json"), "utf8"), ); +const packagedAppSmoke = fs.readFileSync( + path.join(desktopRoot, "scripts", "packagedAppSmoke.js"), + "utf8", +); const frontendProxy = fs.readFileSync( path.join(desktopRoot, "..", "frontend", "src", "proxy.ts"), "utf8", @@ -48,6 +52,19 @@ const genericTransport = fs.readFileSync( "utf8", ); +const backendLocalConfigStart = main.indexOf( + "const BACKEND_LOCAL_CONFIG_ENV_KEYS = [", +); +const backendLocalConfigEnd = main.indexOf("\n];", backendLocalConfigStart); +assert.ok( + backendLocalConfigStart >= 0 && backendLocalConfigEnd > backendLocalConfigStart, + "the backend local configuration allowlist must have an auditable static boundary", +); +const backendLocalConfigSource = main.slice( + backendLocalConfigStart, + backendLocalConfigEnd, +); + assert.match( main, /app\.requestSingleInstanceLock\(\)/, @@ -195,6 +212,47 @@ assert.match( /"ALETHEIA_MODEL_PROVIDER_ALLOW_LOOPBACK_HTTP"/, "the packaged E2E loopback-provider switch must use the explicit backend environment allowlist", ); +assert.doesNotMatch( + backendLocalConfigSource, + /VERA_ENABLE_LEGACY_(?:ROUTES|RUNTIME)/, + "raw Legacy feature values must not enter the backend configuration passthrough allowlist", +); +assert.match( + main, + /VERA_ENABLE_LEGACY_ROUTES:\s*\n?\s*process\.env\.VERA_ENABLE_LEGACY_ROUTES === "true" \? "true" : "false"/, + "Legacy routes must default off and require an exact parent true opt-in", +); +assert.match( + main, + /VERA_ENABLE_LEGACY_RUNTIME:\s*\n?\s*process\.env\.VERA_ENABLE_LEGACY_RUNTIME === "true" \? "true" : "false"/, + "Legacy runtime must default off and require an exact parent true opt-in", +); +assert.match( + main, + /env:\s*\{[\s\S]*?selectedProcessEnvironment\(BACKEND_LOCAL_CONFIG_ENV_KEYS\),[\s\S]*?\.\.\.legacyFeatureEnvironment\(\),[\s\S]*?NODE_ENV: "production"/, + "the formal backend environment must receive both normalized Legacy feature decisions", +); +assert.ok( + desktopPackage.build.extraResources.some( + (entry) => entry.to === "aletheia/backend/voice_sidecar", + ), + "Legacy voice resources must remain packaged until their removal gate is reached", +); +assert.match( + packagedAppSmoke, + /VERA_ENABLE_LEGACY_ROUTES: "false",[\s\S]*?VERA_ENABLE_LEGACY_RUNTIME: "false"/, + "the formal packaged smoke must pin both Legacy surfaces off regardless of its parent shell", +); +assert.match( + packagedAppSmoke, + /health\?\.vera\?\.legacy\?\.status, "disabled"/, + "the packaged smoke must require a truthful disabled Legacy health status", +); +assert.match( + packagedAppSmoke, + /legacyRoute\.status,[\s\S]*?404/, + "the packaged smoke must prove that a retained Legacy route is not mounted by default", +); assert.match( workspaceRuntime, /process\.env\.ALETHEIA_MODEL_PROVIDER_ALLOW_LOOPBACK_HTTP === "true"/, @@ -245,6 +303,9 @@ console.log( "packaged encryption downgrade rejection and truthful mode reporting", "exact loopback-only renderer connect policy", "default-off exact-loopback Generic provider test policy", + "default-off exact-opt-in Legacy routes and runtime", + "packaged health and route proof for disabled Legacy surfaces", + "Legacy compatibility resources retained behind feature gates", ], }, null, diff --git a/desktop/scripts/sqlcipherUtilityAudit.js b/desktop/scripts/sqlcipherUtilityAudit.js index 12d19c1..5ce5c9a 100644 --- a/desktop/scripts/sqlcipherUtilityAudit.js +++ b/desktop/scripts/sqlcipherUtilityAudit.js @@ -66,6 +66,10 @@ async function main() { PATH: process.env.PATH || "", TMPDIR: process.env.TMPDIR || os.tmpdir(), NODE_ENV: "production", + // This audit intentionally exercises the retained Legacy security-policy + // route. It does not need the durable Legacy runtime. + VERA_ENABLE_LEGACY_ROUTES: "true", + VERA_ENABLE_LEGACY_RUNTIME: "false", PORT: String(port), ALETHEIA_BACKEND_HOST: "127.0.0.1", FRONTEND_URL: `http://127.0.0.1:${port + 1}`, diff --git a/docker-compose.yml b/docker-compose.yml index 4b9ce8e..2e65590 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,8 @@ services: ALETHEIA_AUTH_MODE: ${ALETHEIA_AUTH_MODE:-single_user} ALETHEIA_PRIVATE_AUTH_TOKEN: ${ALETHEIA_PRIVATE_AUTH_TOKEN:-} ALETHEIA_AUDIT_HMAC_SECRET: ${ALETHEIA_AUDIT_HMAC_SECRET:-} + VERA_ENABLE_LEGACY_ROUTES: ${VERA_ENABLE_LEGACY_ROUTES:-false} + VERA_ENABLE_LEGACY_RUNTIME: ${VERA_ENABLE_LEGACY_RUNTIME:-false} # The default is an admission-controlled compliance deployment. Do not # change its attestations to true until the operator has verified them. ALETHEIA_DEPLOYMENT_PRESET: ${ALETHEIA_DEPLOYMENT_PRESET:-compliance} diff --git a/docs/convergence/phase-1-legacy-isolation.md b/docs/convergence/phase-1-legacy-isolation.md new file mode 100644 index 0000000..9bedae3 --- /dev/null +++ b/docs/convergence/phase-1-legacy-isolation.md @@ -0,0 +1,119 @@ +# Phase 1 — Legacy Runtime Isolation + +Date: 2026-07-16 + +## Completed + +The active Vera backend now treats Legacy Aletheia as explicit compatibility +surface rather than an unconditional part of normal desktop startup. + +```text +VERA_ENABLE_LEGACY_ROUTES=false +VERA_ENABLE_LEGACY_RUNTIME=false +``` + +Only the exact lowercase value `true` enables either gate. Missing, empty, +`TRUE`, `1`, and other values stay disabled. + +## Behavior matrix + +| Routes | Runtime | Behavior | +| ------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| false | false | Formal Vera default. Legacy router factory and durable/model/voice/demo modules are not loaded by the composition root. `/aletheia/*` is 404. | +| true | false | Explicit compatibility routes. Route-owned Legacy objects may be constructed; the bootstrap durable/model/voice lifecycle and demo seed remain off. | +| false | true | Explicit Legacy background runtime without public Legacy routes. Intended only for controlled migration/diagnostic use. | +| true | true | Full Legacy compatibility mode for retained tests/tools. | + +Production desktop child configuration canonicalizes both values and passes +them explicitly. Ambient non-exact values cannot activate Legacy. Compatibility +audits opt in to the minimum flag combination they require. + +## Health contract + +Existing `vera.workspace` and `vera.audit` fields remain compatible. Health now +also reports: + +```json +{ + "matter": { "status": "not_configured" }, + "conversation": { "status": "not_configured" }, + "legacy": { + "status": "disabled", + "routesEnabled": false, + "runtimeEnabled": false + } +} +``` + +`matter` and `conversation` are intentionally truthful placeholders until their +module phases land. Formal packaged smoke requires Legacy disabled health and a +404 from a retained Legacy route. + +## Files changed + +- backend composition/lifecycle and application audit; +- desktop child environment normalization; +- desktop source/security and packaged smoke gates; +- compatibility audits that intentionally need retained Legacy routes; +- sample/Compose configuration; +- this implementation and rollback record. + +## Migrations added + +None. Phase 1 changes no database schema and deletes no data, table, blob, route +source, test fixture, or packaged Legacy resource. + +## Security implications + +- Normal desktop startup no longer imports route, durable, model, voice, or demo + modules owned solely by Legacy composition. +- Disabled routes are absent (404), not handlers that run after Legacy + constructors. +- The runtime gate covers durable configuration, model/voice shutdown hooks, and + demo seeding. +- Existing loopback, bearer, mutation guard, encryption, Keychain and + backup/restore behavior is unchanged. +- Explicit route compatibility can still construct Legacy route-owned database + and runtime objects; it is not a harmless UI toggle and remains off by + default. + +## Rollback + +No data rollback is required. To restore temporary Legacy compatibility, set +the required gate(s) to exact `true` in a controlled test/migration environment. +To roll back the code, revert the Phase 1 commit; no migration or data deletion +must be undone. The formal client should not use flag opt-in as a permanent +product mode. + +## Known limitations + +- Matter and Conversation modules do not exist yet, so health reports + `not_configured`. +- Legacy source files, frontend deep links, voice sidecar and package resources + remain for migration/regression and are `delete-later`. +- This phase does not solve the audited Workspace background-job audit gate or + migrate Legacy SQLite provider secrets to Keychain. +- A fresh `Vera.app` packaged smoke is required when a new package is produced; + source gates do not substitute for that release artifact. + +## Tests + +Phase-specific and existing regression coverage includes: + +```text +backend build +backend test:workspace:application +backend test:workspace:p0-client +backend test:workspace:p1-convergence +frontend lint / build / test:p0-client +desktop test:p0-source +desktop test:sqlcipher-runtime +desktop product/runtime-security checks +git diff --check +``` + +## Next phase + +Phase 2 starts with additive Workspace migration v15 and MatterProfile +repository/service/API tests. Navigation changes follow in a separate UI commit +after the technical Project-to-Matter extension is verified. From 408333d7a232c92a71544a38b65c83e543cb9fe9 Mon Sep 17 00:00:00 2001 From: shawliu998 <260819717+shawliu998@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:16:04 +0800 Subject: [PATCH 03/10] feat: add matter foundation migration --- backend/src/lib/workspace/migrations/index.ts | 3 + .../workspace/migrations/v15MatterProfiles.ts | 217 ++++ .../veraMatterProfileMigrationAudit.ts | 1106 +++++++++++++++++ .../scripts/veraWorkspaceAssistantAudit.ts | 4 +- .../veraWorkspaceAssistantDurableAudit.ts | 4 +- ...WorkspaceDocumentStudioSuggestionsAudit.ts | 2 +- .../scripts/veraWorkspaceMigrationAudit.ts | 59 +- ...aWorkspaceModelConnectionReadinessAudit.ts | 10 +- .../veraWorkspaceModelSettingsAudit.ts | 2 +- docs/architecture.md | 16 +- .../phase-2a-matter-foundation-migration.md | 89 ++ docs/desktop_app.md | 9 +- docs/mike_port_manifest.md | 4 +- 13 files changed, 1476 insertions(+), 49 deletions(-) create mode 100644 backend/src/lib/workspace/migrations/v15MatterProfiles.ts create mode 100644 backend/src/scripts/veraMatterProfileMigrationAudit.ts create mode 100644 docs/convergence/phase-2a-matter-foundation-migration.md diff --git a/backend/src/lib/workspace/migrations/index.ts b/backend/src/lib/workspace/migrations/index.ts index dc3a024..4d81223 100644 --- a/backend/src/lib/workspace/migrations/index.ts +++ b/backend/src/lib/workspace/migrations/index.ts @@ -12,6 +12,7 @@ import { PROJECT_SOURCE_FOUNDATION_V11_MIGRATION } from "./v11ProjectSourceFound import { DOCUMENT_STUDIO_V12_MIGRATION } from "./v12DocumentStudio"; import { SOURCE_RETENTION_LIFECYCLE_V13_MIGRATION } from "./v13SourceRetentionLifecycle"; import { DOCUMENT_STUDIO_SUGGESTIONS_V14_MIGRATION } from "./v14DocumentStudioSuggestions"; +import { MATTER_PROFILES_V15_MIGRATION } from "./v15MatterProfiles"; export { detectWorkspaceDatabaseCapabilities, @@ -41,6 +42,7 @@ export { PROJECT_SOURCE_FOUNDATION_V11_MIGRATION } from "./v11ProjectSourceFound export { DOCUMENT_STUDIO_V12_MIGRATION } from "./v12DocumentStudio"; export { SOURCE_RETENTION_LIFECYCLE_V13_MIGRATION } from "./v13SourceRetentionLifecycle"; export { DOCUMENT_STUDIO_SUGGESTIONS_V14_MIGRATION } from "./v14DocumentStudioSuggestions"; +export { MATTER_PROFILES_V15_MIGRATION } from "./v15MatterProfiles"; export const WORKSPACE_MIGRATIONS = [ INITIAL_WORKSPACE_MIGRATION, @@ -57,4 +59,5 @@ export const WORKSPACE_MIGRATIONS = [ DOCUMENT_STUDIO_V12_MIGRATION, SOURCE_RETENTION_LIFECYCLE_V13_MIGRATION, DOCUMENT_STUDIO_SUGGESTIONS_V14_MIGRATION, + MATTER_PROFILES_V15_MIGRATION, ] as const; diff --git a/backend/src/lib/workspace/migrations/v15MatterProfiles.ts b/backend/src/lib/workspace/migrations/v15MatterProfiles.ts new file mode 100644 index 0000000..aa067bf --- /dev/null +++ b/backend/src/lib/workspace/migrations/v15MatterProfiles.ts @@ -0,0 +1,217 @@ +import type { + WorkspaceDatabaseAdapter, + WorkspaceDatabaseCapabilities, + WorkspaceMigration, +} from "./types"; + +const CREATED_AT = "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"; + +const strictUtcTimestamp = (column: string) => ` + typeof(${column}) = 'text' + AND length(${column}) = 24 + AND ${column} GLOB + '[0-9][0-9][0-9][0-9]-[0-1][0-9]-[0-3][0-9]T[0-2][0-9]:[0-5][0-9]:[0-5][0-9].[0-9][0-9][0-9]Z' + AND strftime('%Y-%m-%dT%H:%M:%fZ', ${column}) = ${column} +`; + +const boundedNullableText = (column: string, maximum: number) => ` + ${column} IS NULL OR ( + typeof(${column}) = 'text' + AND length(trim(${column})) BETWEEN 1 AND ${maximum} + AND instr(${column}, char(0)) = 0 + ) +`; + +/* + * A Matter Profile is the optional legal-semantic extension of the existing + * Project ownership boundary. It intentionally contains intake metadata only: + * sourced facts, AI findings, review decisions, and other durable legal state + * belong in the later Artifact Graph rather than this table. + */ +const MATTER_PROFILES_V15_SQL = ` +CREATE TABLE matter_profiles ( + project_id TEXT PRIMARY KEY + CHECK ( + typeof(project_id) = 'text' + AND length(trim(project_id)) BETWEEN 1 AND 120 + AND instr(project_id, char(0)) = 0 + ) + REFERENCES projects(id) ON DELETE CASCADE, + matter_type TEXT NOT NULL + CHECK ( + typeof(matter_type) = 'text' + AND matter_type IN ( + 'civil_litigation', + 'commercial_dispute', + 'contract_review', + 'legal_research', + 'general' + ) + ), + client_name TEXT CHECK (${boundedNullableText("client_name", 500)}), + represented_role TEXT CHECK ( + ${boundedNullableText("represented_role", 240)} + ), + counterparty TEXT CHECK (${boundedNullableText("counterparty", 1000)}), + court TEXT CHECK (${boundedNullableText("court", 500)}), + case_number TEXT CHECK (${boundedNullableText("case_number", 240)}), + stage TEXT CHECK (${boundedNullableText("stage", 240)}), + objective TEXT CHECK (${boundedNullableText("objective", 16384)}), + risk_level TEXT CHECK ( + risk_level IS NULL OR ( + typeof(risk_level) = 'text' + AND risk_level IN ('low', 'medium', 'high') + ) + ), + opened_at TEXT CHECK ( + opened_at IS NULL OR (${strictUtcTimestamp("opened_at")}) + ), + closed_at TEXT CHECK ( + closed_at IS NULL OR (${strictUtcTimestamp("closed_at")}) + ), + created_at TEXT NOT NULL DEFAULT ${CREATED_AT} + CHECK (${strictUtcTimestamp("created_at")}), + updated_at TEXT NOT NULL DEFAULT ${CREATED_AT} + CHECK (${strictUtcTimestamp("updated_at")}), + CHECK (closed_at IS NULL OR opened_at IS NULL OR closed_at >= opened_at), + CHECK (updated_at >= created_at) +) WITHOUT ROWID; + +CREATE INDEX idx_matter_profiles_type_updated + ON matter_profiles(matter_type, updated_at DESC, project_id); + +CREATE INDEX idx_matter_profiles_risk_updated + ON matter_profiles(risk_level, updated_at DESC, project_id) + WHERE risk_level IS NOT NULL; + +CREATE INDEX idx_matter_profiles_case_number + ON matter_profiles(case_number, project_id) + WHERE case_number IS NOT NULL; + +CREATE TRIGGER matter_profiles_v15_update_guard +BEFORE UPDATE ON matter_profiles BEGIN + SELECT CASE WHEN new.project_id IS NOT old.project_id + THEN RAISE(ABORT, 'Matter Profile Project ownership is immutable') + END; + SELECT CASE WHEN new.created_at IS NOT old.created_at + THEN RAISE(ABORT, 'Matter Profile creation time is immutable') + END; + SELECT CASE WHEN new.updated_at < old.updated_at + THEN RAISE(ABORT, 'Matter Profile update time cannot move backwards') + END; +END; + +CREATE TABLE matter_policies ( + project_id TEXT PRIMARY KEY + CHECK ( + typeof(project_id) = 'text' + AND length(trim(project_id)) BETWEEN 1 AND 120 + AND instr(project_id, char(0)) = 0 + ) + REFERENCES matter_profiles(project_id) ON DELETE CASCADE, + external_egress_mode TEXT NOT NULL DEFAULT 'disabled' + CHECK ( + typeof(external_egress_mode) = 'text' + AND external_egress_mode IN ( + 'disabled', + 'approval', + 'allowed_by_policy' + ) + ), + audio_retention_days INTEGER + CHECK ( + audio_retention_days IS NULL OR ( + typeof(audio_retention_days) = 'integer' + AND audio_retention_days BETWEEN 0 AND 36500 + ) + ), + allow_external_legal_sources INTEGER NOT NULL DEFAULT 0 + CHECK ( + typeof(allow_external_legal_sources) = 'integer' + AND allow_external_legal_sources IN (0, 1) + ), + allow_word_bridge INTEGER NOT NULL DEFAULT 0 + CHECK ( + typeof(allow_word_bridge) = 'integer' + AND allow_word_bridge IN (0, 1) + ), + created_at TEXT NOT NULL DEFAULT ${CREATED_AT} + CHECK (${strictUtcTimestamp("created_at")}), + updated_at TEXT NOT NULL DEFAULT ${CREATED_AT} + CHECK (${strictUtcTimestamp("updated_at")}), + CHECK (updated_at >= created_at) +) WITHOUT ROWID; + +CREATE INDEX idx_matter_policies_egress_updated + ON matter_policies(external_egress_mode, updated_at DESC, project_id); + +CREATE TRIGGER matter_policies_v15_update_guard +BEFORE UPDATE ON matter_policies BEGIN + SELECT CASE WHEN new.project_id IS NOT old.project_id + THEN RAISE(ABORT, 'Matter Policy Project ownership is immutable') + END; + SELECT CASE WHEN new.created_at IS NOT old.created_at + THEN RAISE(ABORT, 'Matter Policy creation time is immutable') + END; + SELECT CASE WHEN new.updated_at < old.updated_at + THEN RAISE(ABORT, 'Matter Policy update time cannot move backwards') + END; +END; + +/* + * Zero rows is the canonical deny-all execution-location set. A missing + * matter_policies row is also interpreted fail-closed by policy evaluation; + * neither state silently enables local or remote inference. + */ +CREATE TABLE matter_policy_execution_locations ( + project_id TEXT NOT NULL + REFERENCES matter_policies(project_id) ON DELETE CASCADE, + execution_location TEXT NOT NULL + CHECK ( + typeof(execution_location) = 'text' + AND execution_location IN ( + 'local', + 'firm_private', + 'confidential_remote', + 'standard_remote' + ) + ), + created_at TEXT NOT NULL DEFAULT ${CREATED_AT} + CHECK (${strictUtcTimestamp("created_at")}), + PRIMARY KEY (project_id, execution_location) +) WITHOUT ROWID; + +CREATE INDEX idx_matter_policy_execution_locations_location + ON matter_policy_execution_locations(execution_location, project_id); + +CREATE TRIGGER matter_policy_execution_locations_v15_immutable +BEFORE UPDATE ON matter_policy_execution_locations BEGIN + SELECT RAISE( + ABORT, + 'Matter Policy execution-location membership is immutable; replace it' + ); +END; +`; + +function applyMatterProfilesV15( + database: WorkspaceDatabaseAdapter, + _capabilities: WorkspaceDatabaseCapabilities, +) { + database.exec(MATTER_PROFILES_V15_SQL); +} + +export const MATTER_PROFILES_V15_MIGRATION: WorkspaceMigration = { + version: 15, + name: "project_matter_foundation", + checksumMaterial: [ + "workspace-migration-v15", + "additive-optional-project-one-to-one-legal-semantic-profile", + "intake-metadata-only-no-sources-ai-findings-or-formal-matter-state", + "optional-profile-owned-policy-with-normalized-execution-locations", + "missing-policy-and-empty-execution-set-are-deny-all", + "default-egress-disabled-audio-retention-unconfigured-external-and-word-off", + "strict-bounded-text-enums-canonical-utc-times-and-cascade-ownership", + MATTER_PROFILES_V15_SQL, + ].join("\n-- checksum boundary --\n"), + apply: applyMatterProfilesV15, +}; diff --git a/backend/src/scripts/veraMatterProfileMigrationAudit.ts b/backend/src/scripts/veraMatterProfileMigrationAudit.ts new file mode 100644 index 0000000..defbd78 --- /dev/null +++ b/backend/src/scripts/veraMatterProfileMigrationAudit.ts @@ -0,0 +1,1106 @@ +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + runWorkspaceMigrations, + WorkspaceDatabase, + workspaceMigrationChecksum, +} from "../lib/workspace/database"; +import { + MATTER_PROFILES_V15_MIGRATION, + WORKSPACE_MIGRATIONS, + type WorkspaceDatabaseAdapter, + type WorkspaceMigration, + type WorkspaceStatement, +} from "../lib/workspace/migrations"; + +const originalEnvironment = { ...process.env }; +const root = mkdtempSync( + path.join(os.tmpdir(), "vera-matter-profile-v15-audit-"), +); +const V14_MIGRATIONS = WORKSPACE_MIGRATIONS.slice(0, 14); +const now = "2026-07-16T08:00:00.000Z"; +const later = "2026-07-16T09:00:00.000Z"; + +function object(row: Record | undefined) { + assert.ok(row); + return { ...row }; +} + +function insertProject( + database: WorkspaceDatabase, + id: string, + name = "Matter migration audit Project", +) { + database + .prepare( + `INSERT INTO projects (id, name, status, created_at, updated_at) + VALUES (?, ?, 'active', ?, ?)`, + ) + .run(id, name, now, now); +} + +type MatterRow = { + projectId?: unknown; + matterType?: unknown; + clientName?: unknown; + representedRole?: unknown; + counterparty?: unknown; + court?: unknown; + caseNumber?: unknown; + stage?: unknown; + objective?: unknown; + riskLevel?: unknown; + openedAt?: unknown; + closedAt?: unknown; + createdAt?: unknown; + updatedAt?: unknown; +}; + +function insertMatterProfile( + database: WorkspaceDatabase, + input: MatterRow = {}, +) { + database + .prepare( + `INSERT INTO matter_profiles ( + project_id, matter_type, client_name, represented_role, + counterparty, court, case_number, stage, objective, risk_level, + opened_at, closed_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + input.projectId === undefined ? "project-constraints" : input.projectId, + input.matterType === undefined ? "civil_litigation" : input.matterType, + input.clientName === undefined ? "Audit Client" : input.clientName, + input.representedRole === undefined ? "Plaintiff" : input.representedRole, + input.counterparty === undefined + ? "Audit Counterparty" + : input.counterparty, + input.court === undefined ? "Audit Court" : input.court, + input.caseNumber === undefined ? "(2026) Audit 15" : input.caseNumber, + input.stage === undefined ? "intake" : input.stage, + input.objective === undefined + ? "Preserve the client's reviewed objective." + : input.objective, + input.riskLevel === undefined ? "medium" : input.riskLevel, + input.openedAt === undefined ? now : input.openedAt, + input.closedAt === undefined ? null : input.closedAt, + input.createdAt === undefined ? now : input.createdAt, + input.updatedAt === undefined ? now : input.updatedAt, + ); +} + +type MatterPolicyRow = { + projectId?: unknown; + externalEgressMode?: unknown; + audioRetentionDays?: unknown; + allowExternalLegalSources?: unknown; + allowWordBridge?: unknown; + createdAt?: unknown; + updatedAt?: unknown; +}; + +function insertMatterPolicy( + database: WorkspaceDatabase, + input: MatterPolicyRow = {}, +) { + database + .prepare( + `INSERT INTO matter_policies ( + project_id, external_egress_mode, audio_retention_days, + allow_external_legal_sources, allow_word_bridge, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + input.projectId === undefined + ? "project-policy-constraints" + : input.projectId, + input.externalEgressMode === undefined + ? "disabled" + : input.externalEgressMode, + input.audioRetentionDays === undefined ? null : input.audioRetentionDays, + input.allowExternalLegalSources === undefined + ? 0 + : input.allowExternalLegalSources, + input.allowWordBridge === undefined ? 0 : input.allowWordBridge, + input.createdAt === undefined ? now : input.createdAt, + input.updatedAt === undefined ? now : input.updatedAt, + ); +} + +function policyAllowsExecutionLocation( + database: WorkspaceDatabase, + projectId: string, + executionLocation: string, +) { + return Boolean( + database + .prepare( + `SELECT 1 AS allowed + FROM matter_policies policy + JOIN matter_policy_execution_locations location + ON location.project_id = policy.project_id + WHERE policy.project_id = ? + AND location.execution_location = ?`, + ) + .get(projectId, executionLocation), + ); +} + +function assertConstraint( + database: WorkspaceDatabase, + input: MatterRow, + message?: string, +) { + assert.throws( + () => insertMatterProfile(database, input), + /constraint|foreign key/i, + message, + ); + assert.equal( + database + .prepare( + "SELECT count(*) AS count FROM matter_profiles WHERE project_id = 'project-constraints'", + ) + .get()?.count, + 0, + ); +} + +function assertPolicyConstraint( + database: WorkspaceDatabase, + input: MatterPolicyRow, +) { + assert.throws(() => insertMatterPolicy(database, input), /constraint/i); + assert.equal( + database + .prepare( + `SELECT count(*) AS count + FROM matter_policies + WHERE project_id = 'project-policy-constraints'`, + ) + .get()?.count, + 0, + ); +} + +function schemaNames( + database: WorkspaceDatabase, + type: "table" | "index" | "trigger", +) { + return new Set( + database + .prepare( + `SELECT name + FROM sqlite_schema + WHERE type = ? AND name NOT LIKE 'sqlite_%'`, + ) + .all(type) + .map((row) => String(row.name)), + ); +} + +function auditFreshInstallAndStrictConstraints() { + const database = new WorkspaceDatabase(path.join(root, "fresh.db")); + try { + assert.equal(database.migration?.currentVersion, 15); + assert.deepEqual( + database.migration?.applied.map((entry) => entry.version), + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + ); + assert.equal(WORKSPACE_MIGRATIONS.at(14), MATTER_PROFILES_V15_MIGRATION); + assert.deepEqual( + database + .prepare("PRAGMA table_info('matter_profiles')") + .all() + .map((row) => String(row.name)), + [ + "project_id", + "matter_type", + "client_name", + "represented_role", + "counterparty", + "court", + "case_number", + "stage", + "objective", + "risk_level", + "opened_at", + "closed_at", + "created_at", + "updated_at", + ], + ); + for (const index of [ + "idx_matter_profiles_type_updated", + "idx_matter_profiles_risk_updated", + "idx_matter_profiles_case_number", + "idx_matter_policies_egress_updated", + "idx_matter_policy_execution_locations_location", + ]) { + assert.equal(schemaNames(database, "index").has(index), true, index); + } + for (const trigger of [ + "matter_profiles_v15_update_guard", + "matter_policies_v15_update_guard", + "matter_policy_execution_locations_v15_immutable", + ]) { + assert.equal( + schemaNames(database, "trigger").has(trigger), + true, + trigger, + ); + } + const tableSql = String( + database + .prepare( + `SELECT sql FROM sqlite_schema + WHERE type = 'table' AND name = 'matter_profiles'`, + ) + .get()?.sql, + ); + assert.match(tableSql, /WITHOUT ROWID/i); + assert.match(tableSql, /REFERENCES projects\(id\) ON DELETE CASCADE/i); + for (const value of [ + "civil_litigation", + "commercial_dispute", + "contract_review", + "legal_research", + "general", + "low", + "medium", + "high", + ]) { + assert.equal(tableSql.includes(`'${value}'`), true, value); + } + assert.match(tableSql, /strftime\('%Y-%m-%dT%H:%M:%fZ'/); + + const policySql = String( + database + .prepare( + `SELECT sql FROM sqlite_schema + WHERE type = 'table' AND name = 'matter_policies'`, + ) + .get()?.sql, + ); + assert.match( + policySql, + /REFERENCES matter_profiles\(project_id\) ON DELETE CASCADE/i, + ); + assert.match(policySql, /audio_retention_days BETWEEN 0 AND 36500/i); + assert.match(policySql, /allow_external_legal_sources IN \(0, 1\)/i); + assert.match(policySql, /allow_word_bridge IN \(0, 1\)/i); + const executionLocationSql = String( + database + .prepare( + `SELECT sql FROM sqlite_schema + WHERE type = 'table' + AND name = 'matter_policy_execution_locations'`, + ) + .get()?.sql, + ); + for (const location of [ + "local", + "firm_private", + "confidential_remote", + "standard_remote", + ]) { + assert.equal(executionLocationSql.includes(`'${location}'`), true); + } + + insertProject(database, "project-ordinary", "Ordinary Project"); + insertProject(database, "project-full", "Full Matter"); + insertProject(database, "project-constraints", "Constraint Matter"); + insertProject(database, "project-policy", "Policy Matter"); + insertProject( + database, + "project-policy-constraints", + "Policy Constraint Matter", + ); + assert.equal( + database + .prepare( + "SELECT count(*) AS count FROM matter_profiles WHERE project_id = 'project-ordinary'", + ) + .get()?.count, + 0, + "a normal Project remains valid without a Matter Profile", + ); + + insertMatterProfile(database, { + projectId: "project-full", + matterType: "commercial_dispute", + clientName: "Vera Client", + representedRole: "Respondent", + counterparty: "Example Counterparty", + court: "Example Commercial Court", + caseNumber: "(2026) Vera 15", + stage: "discovery", + objective: "Resolve the dispute on reviewed terms.", + riskLevel: "high", + openedAt: now, + closedAt: later, + createdAt: now, + updatedAt: later, + }); + assert.deepEqual( + object( + database + .prepare( + `SELECT project_id, matter_type, client_name, represented_role, + counterparty, court, case_number, stage, objective, + risk_level, opened_at, closed_at, created_at, updated_at + FROM matter_profiles + WHERE project_id = 'project-full'`, + ) + .get(), + ), + { + project_id: "project-full", + matter_type: "commercial_dispute", + client_name: "Vera Client", + represented_role: "Respondent", + counterparty: "Example Counterparty", + court: "Example Commercial Court", + case_number: "(2026) Vera 15", + stage: "discovery", + objective: "Resolve the dispute on reviewed terms.", + risk_level: "high", + opened_at: now, + closed_at: later, + created_at: now, + updated_at: later, + }, + ); + assert.throws( + () => + insertMatterProfile(database, { + projectId: "project-full", + matterType: "general", + }), + /unique|primary key/i, + "one Project cannot own two Matter Profiles", + ); + assertConstraint(database, { projectId: "missing-project" }); + + for (const matterType of ["", "Civil_litigation", "litigation", null]) { + assertConstraint(database, { matterType }); + } + for (const riskLevel of ["", "critical", "High"]) { + assertConstraint(database, { riskLevel }); + } + for (const [field, maximum] of [ + ["clientName", 500], + ["representedRole", 240], + ["counterparty", 1000], + ["court", 500], + ["caseNumber", 240], + ["stage", 240], + ["objective", 16384], + ] as const) { + assertConstraint(database, { [field]: " " }); + assertConstraint(database, { [field]: "x".repeat(maximum + 1) }); + assertConstraint(database, { [field]: `safe\0unsafe` }); + } + for (const openedAt of [ + "", + "2026-07-16T08:00:00Z", + "2026-07-16T08:00:00.000+00:00", + "2026-02-30T08:00:00.000Z", + ]) { + assertConstraint(database, { openedAt }); + } + assertConstraint(database, { + openedAt: later, + closedAt: now, + }); + assertConstraint(database, { + createdAt: "2026-07-16T08:00:00Z", + }); + assertConstraint(database, { + updatedAt: "2026-07-16T08:00:00+00:00", + }); + assertConstraint(database, { + createdAt: later, + updatedAt: now, + }); + + database + .prepare( + `UPDATE matter_profiles + SET stage = 'hearing', updated_at = updated_at + WHERE project_id = 'project-full'`, + ) + .run(); + assert.throws( + () => + database + .prepare( + `UPDATE matter_profiles + SET updated_at = ? + WHERE project_id = 'project-full'`, + ) + .run(now), + /cannot move backwards/i, + ); + assert.throws( + () => + database + .prepare( + `UPDATE matter_profiles + SET created_at = ? + WHERE project_id = 'project-full'`, + ) + .run(later), + /creation time is immutable/i, + ); + assert.throws( + () => + database + .prepare( + `UPDATE matter_profiles + SET project_id = 'project-ordinary' + WHERE project_id = 'project-full'`, + ) + .run(), + /Project ownership is immutable/i, + ); + + insertMatterProfile(database, { + projectId: "project-policy", + matterType: "general", + clientName: null, + representedRole: null, + counterparty: null, + court: null, + caseNumber: null, + stage: null, + objective: null, + riskLevel: null, + openedAt: null, + closedAt: null, + }); + insertMatterProfile(database, { + projectId: "project-policy-constraints", + matterType: "legal_research", + }); + assert.equal( + policyAllowsExecutionLocation(database, "project-full", "local"), + false, + "a missing Matter Policy is deny-all", + ); + assert.throws( + () => + insertMatterPolicy(database, { + projectId: "project-ordinary", + }), + /foreign key/i, + "an ordinary Project cannot receive a Matter Policy without a Profile", + ); + assert.throws( + () => + insertMatterPolicy(database, { + projectId: "missing-project", + }), + /foreign key/i, + ); + + database + .prepare( + `INSERT INTO matter_policies (project_id, created_at, updated_at) + VALUES ('project-policy', ?, ?)`, + ) + .run(now, now); + assert.deepEqual( + object( + database + .prepare( + `SELECT external_egress_mode, audio_retention_days, + allow_external_legal_sources, allow_word_bridge, + created_at, updated_at + FROM matter_policies + WHERE project_id = 'project-policy'`, + ) + .get(), + ), + { + external_egress_mode: "disabled", + audio_retention_days: null, + allow_external_legal_sources: 0, + allow_word_bridge: 0, + created_at: now, + updated_at: now, + }, + "policy defaults do not silently enable egress, retention, sources, or Word", + ); + assert.equal( + policyAllowsExecutionLocation(database, "project-policy", "local"), + false, + "an empty execution-location set is deny-all", + ); + + for (const externalEgressMode of ["", "allowed", "Disabled", null]) { + assertPolicyConstraint(database, { externalEgressMode }); + } + for (const audioRetentionDays of [-1, 36_501, 1.5, "thirty"]) { + assertPolicyConstraint(database, { audioRetentionDays }); + } + for (const allowExternalLegalSources of [-1, 2, 0.5, "yes"]) { + assertPolicyConstraint(database, { allowExternalLegalSources }); + } + for (const allowWordBridge of [-1, 2, 0.5, "yes"]) { + assertPolicyConstraint(database, { allowWordBridge }); + } + assertPolicyConstraint(database, { + createdAt: "2026-07-16T08:00:00Z", + }); + assertPolicyConstraint(database, { + updatedAt: "2026-07-16T08:00:00+00:00", + }); + assertPolicyConstraint(database, { + createdAt: later, + updatedAt: now, + }); + + database + .prepare( + `INSERT INTO matter_policy_execution_locations + (project_id, execution_location, created_at) + VALUES ('project-policy', 'local', ?)`, + ) + .run(now); + assert.equal( + policyAllowsExecutionLocation(database, "project-policy", "local"), + true, + ); + assert.equal( + policyAllowsExecutionLocation( + database, + "project-policy", + "standard_remote", + ), + false, + ); + assert.throws( + () => + database + .prepare( + `INSERT INTO matter_policy_execution_locations + (project_id, execution_location, created_at) + VALUES ('project-policy', 'local', ?)`, + ) + .run(now), + /unique|primary key/i, + ); + for (const executionLocation of ["", "remote", "LOCAL", null]) { + assert.throws( + () => + database + .prepare( + `INSERT INTO matter_policy_execution_locations + (project_id, execution_location, created_at) + VALUES ('project-policy', ?, ?)`, + ) + .run(executionLocation, now), + /constraint/i, + ); + } + assert.throws( + () => + database + .prepare( + `INSERT INTO matter_policy_execution_locations + (project_id, execution_location, created_at) + VALUES ('project-ordinary', 'local', ?)`, + ) + .run(now), + /foreign key/i, + "execution-location membership cannot cross into a Project without its own Policy", + ); + assert.throws( + () => + database + .prepare( + `INSERT INTO matter_policy_execution_locations + (project_id, execution_location, created_at) + VALUES ('project-policy', 'firm_private', + '2026-07-16T08:00:00Z')`, + ) + .run(), + /constraint/i, + ); + assert.throws( + () => + database + .prepare( + `UPDATE matter_policy_execution_locations + SET execution_location = 'firm_private' + WHERE project_id = 'project-policy' + AND execution_location = 'local'`, + ) + .run(), + /membership is immutable/i, + ); + + database + .prepare( + `UPDATE matter_policies + SET external_egress_mode = 'approval', + audio_retention_days = 30, + allow_external_legal_sources = 1, + allow_word_bridge = 1, + updated_at = ? + WHERE project_id = 'project-policy'`, + ) + .run(later); + assert.throws( + () => + database + .prepare( + `UPDATE matter_policies + SET updated_at = ? + WHERE project_id = 'project-policy'`, + ) + .run(now), + /cannot move backwards/i, + ); + assert.throws( + () => + database + .prepare( + `UPDATE matter_policies + SET created_at = ? + WHERE project_id = 'project-policy'`, + ) + .run(later), + /creation time is immutable/i, + ); + assert.throws( + () => + database + .prepare( + `UPDATE matter_policies + SET project_id = 'project-policy-constraints' + WHERE project_id = 'project-policy'`, + ) + .run(), + /Project ownership is immutable/i, + ); + + database + .prepare( + "DELETE FROM matter_profiles WHERE project_id = 'project-policy'", + ) + .run(); + assert.equal( + database + .prepare( + "SELECT count(*) AS count FROM matter_policies WHERE project_id = 'project-policy'", + ) + .get()?.count, + 0, + "Matter Profile deletion cascades its Policy", + ); + assert.equal( + database + .prepare( + `SELECT count(*) AS count + FROM matter_policy_execution_locations + WHERE project_id = 'project-policy'`, + ) + .get()?.count, + 0, + "Matter Profile deletion cascades normalized execution locations", + ); + assert.equal( + database + .prepare( + "SELECT count(*) AS count FROM projects WHERE id = 'project-policy'", + ) + .get()?.count, + 1, + "removing optional legal semantics does not remove its Project", + ); + + database.prepare("DELETE FROM projects WHERE id = 'project-full'").run(); + assert.equal( + database + .prepare( + "SELECT count(*) AS count FROM matter_profiles WHERE project_id = 'project-full'", + ) + .get()?.count, + 0, + "Project deletion cascades to its optional Matter Profile", + ); + assert.deepEqual(database.prepare("PRAGMA foreign_key_check").all(), []); + } finally { + database.close(); + } +} + +function seedV14UpgradeFixture(database: WorkspaceDatabase) { + insertProject(database, "project-v14", "Preserved v14 Project"); + database.exec(` + CREATE TABLE matter_v15_legacy_sentinel ( + id INTEGER PRIMARY KEY, + payload TEXT NOT NULL + ); + INSERT INTO matter_v15_legacy_sentinel (id, payload) + VALUES (1, 'v14-data-must-survive'); + `); + database + .prepare( + `INSERT INTO documents + (id, project_id, title, filename, mime_type, size_bytes, + created_at, updated_at) + VALUES ('document-v14', 'project-v14', 'Preserved document', + 'preserved.txt', 'text/plain', 9, ?, ?)`, + ) + .run(now, now); +} + +function auditV14UpgradeChecksumAndIdempotence() { + const databasePath = path.join(root, "upgrade.db"); + const v14 = new WorkspaceDatabase(databasePath, { + migrations: V14_MIGRATIONS, + }); + let oldMigrationRows: Array>; + try { + assert.equal(v14.migration?.currentVersion, 14); + seedV14UpgradeFixture(v14); + oldMigrationRows = v14 + .prepare( + `SELECT version, name, checksum + FROM workspace_schema_migrations + ORDER BY version`, + ) + .all() + .map((row) => ({ ...row })); + } finally { + v14.close(); + } + + const upgraded = new WorkspaceDatabase(databasePath); + try { + assert.equal(upgraded.migration?.currentVersion, 15); + assert.deepEqual( + upgraded.migration?.applied.map((entry) => entry.version), + [15], + ); + assert.deepEqual( + upgraded + .prepare( + `SELECT version, name, checksum + FROM workspace_schema_migrations + WHERE version <= 14 + ORDER BY version`, + ) + .all() + .map((row) => ({ ...row })), + oldMigrationRows, + "v15 must not rewrite any prior migration record", + ); + assert.deepEqual( + object( + upgraded + .prepare("SELECT name, status FROM projects WHERE id = 'project-v14'") + .get(), + ), + { name: "Preserved v14 Project", status: "active" }, + ); + assert.equal( + upgraded + .prepare("SELECT title FROM documents WHERE id = 'document-v14'") + .get()?.title, + "Preserved document", + ); + assert.equal( + upgraded + .prepare("SELECT payload FROM matter_v15_legacy_sentinel WHERE id = 1") + .get()?.payload, + "v14-data-must-survive", + ); + for (const table of [ + "matter_profiles", + "matter_policies", + "matter_policy_execution_locations", + ]) { + assert.equal( + upgraded.prepare(`SELECT count(*) AS count FROM ${table}`).get()?.count, + 0, + `upgrade does not guess or backfill ${table} for ordinary Projects`, + ); + } + const v15Record = object( + upgraded + .prepare( + `SELECT version, name, checksum + FROM workspace_schema_migrations + WHERE version = 15`, + ) + .get(), + ); + assert.deepEqual(v15Record, { + version: 15, + name: MATTER_PROFILES_V15_MIGRATION.name, + checksum: workspaceMigrationChecksum(MATTER_PROFILES_V15_MIGRATION), + }); + const rerun = upgraded.runMigrations(); + assert.equal(rerun.currentVersion, 15); + assert.deepEqual(rerun.applied, []); + + const driftedV15: WorkspaceMigration = { + ...MATTER_PROFILES_V15_MIGRATION, + checksumMaterial: `${MATTER_PROFILES_V15_MIGRATION.checksumMaterial}\n-- unauthorized drift`, + }; + assert.throws( + () => upgraded.runMigrations([...V14_MIGRATIONS, driftedV15]), + /checksum drift/i, + ); + assert.equal( + upgraded + .prepare( + "SELECT checksum FROM workspace_schema_migrations WHERE version = 15", + ) + .get()?.checksum, + workspaceMigrationChecksum(MATTER_PROFILES_V15_MIGRATION), + ); + } finally { + upgraded.close(); + } + + const reopened = new WorkspaceDatabase(databasePath); + try { + assert.equal(reopened.migration?.currentVersion, 15); + assert.deepEqual(reopened.migration?.applied, []); + assert.equal( + reopened + .prepare("SELECT payload FROM matter_v15_legacy_sentinel WHERE id = 1") + .get()?.payload, + "v14-data-must-survive", + ); + } finally { + reopened.close(); + } +} + +function failAfterV15Apply( + database: WorkspaceDatabase, + onCompleteApply: () => void, +): WorkspaceDatabaseAdapter { + return { + exec(sql) { + database.exec(sql); + }, + prepare(sql) { + const statement = database.prepare(sql); + if (!/INSERT INTO workspace_schema_migrations/i.test(sql)) { + return statement; + } + const wrapped: WorkspaceStatement = { + run(...parameters: unknown[]) { + if (Number(parameters[0]) === 15) { + onCompleteApply(); + throw new Error("injected fault after complete v15 apply"); + } + return statement.run(...parameters); + }, + get(...parameters: unknown[]) { + return statement.get(...parameters); + }, + all(...parameters: unknown[]) { + return statement.all(...parameters); + }, + }; + return wrapped; + }, + }; +} + +function auditPostApplyFailureRollsBack() { + const database = new WorkspaceDatabase(path.join(root, "rollback.db"), { + migrations: V14_MIGRATIONS, + }); + try { + insertProject(database, "project-rollback", "Rollback Project"); + let observedCompleteApply = false; + assert.throws( + () => + runWorkspaceMigrations( + failAfterV15Apply(database, () => { + observedCompleteApply = + schemaNames(database, "table").has("matter_profiles") && + schemaNames(database, "table").has("matter_policies") && + schemaNames(database, "table").has( + "matter_policy_execution_locations", + ) && + schemaNames(database, "trigger").has( + "matter_profiles_v15_update_guard", + ); + }), + WORKSPACE_MIGRATIONS, + ), + /failed and was rolled back/i, + ); + assert.equal(observedCompleteApply, true); + for (const table of [ + "matter_profiles", + "matter_policies", + "matter_policy_execution_locations", + ]) { + assert.equal(schemaNames(database, "table").has(table), false, table); + } + assert.equal( + schemaNames(database, "trigger").has("matter_profiles_v15_update_guard"), + false, + ); + assert.equal( + database + .prepare( + "SELECT max(version) AS version FROM workspace_schema_migrations", + ) + .get()?.version, + 14, + ); + assert.equal( + database + .prepare("SELECT name FROM projects WHERE id = 'project-rollback'") + .get()?.name, + "Rollback Project", + ); + assert.deepEqual(database.prepare("PRAGMA foreign_key_check").all(), []); + } finally { + database.close(); + } +} + +function auditSqlcipherV14Upgrade() { + process.env.ALETHEIA_DATABASE_ENCRYPTION = "sqlcipher_required"; + process.env.ALETHEIA_DATABASE_KEY_SOURCE = "env"; + process.env.ALETHEIA_DATABASE_KEY_BASE64 = randomBytes(32).toString("base64"); + const databasePath = path.join(root, "encrypted-upgrade.db"); + const v14 = new WorkspaceDatabase(databasePath, { + migrations: V14_MIGRATIONS, + }); + try { + assert.equal(v14.migration?.currentVersion, 14); + assert.equal(v14.migration?.capabilities.sqlcipherEncrypted, true); + insertProject(v14, "project-encrypted", "Encrypted Project"); + } finally { + v14.close(); + } + + const upgraded = new WorkspaceDatabase(databasePath); + try { + assert.equal(upgraded.migration?.currentVersion, 15); + assert.deepEqual( + upgraded.migration?.applied.map((entry) => entry.version), + [15], + ); + assert.equal(upgraded.migration?.capabilities.sqlcipherEncrypted, true); + assert.equal( + upgraded + .prepare("SELECT name FROM projects WHERE id = 'project-encrypted'") + .get()?.name, + "Encrypted Project", + ); + insertMatterProfile(upgraded, { + projectId: "project-encrypted", + matterType: "general", + clientName: null, + representedRole: null, + counterparty: null, + court: null, + caseNumber: null, + stage: null, + objective: null, + riskLevel: null, + openedAt: null, + closedAt: null, + }); + assert.equal( + upgraded + .prepare( + "SELECT matter_type FROM matter_profiles WHERE project_id = 'project-encrypted'", + ) + .get()?.matter_type, + "general", + ); + insertMatterPolicy(upgraded, { + projectId: "project-encrypted", + externalEgressMode: "approval", + audioRetentionDays: 7, + allowExternalLegalSources: 1, + allowWordBridge: 0, + }); + upgraded + .prepare( + `INSERT INTO matter_policy_execution_locations + (project_id, execution_location, created_at) + VALUES ('project-encrypted', 'firm_private', ?)`, + ) + .run(now); + assert.equal( + policyAllowsExecutionLocation( + upgraded, + "project-encrypted", + "firm_private", + ), + true, + ); + assert.deepEqual(upgraded.prepare("PRAGMA foreign_key_check").all(), []); + } finally { + upgraded.close(); + } + assert.notEqual( + readFileSync(databasePath).subarray(0, 16).toString("utf8"), + "SQLite format 3\0", + ); +} + +try { + process.env.ALETHEIA_DATABASE_ENCRYPTION = "metadata_plaintext"; + assert.deepEqual( + WORKSPACE_MIGRATIONS.map((migration) => migration.version), + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + ); + assert.deepEqual( + V14_MIGRATIONS.map((migration) => migration.version), + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + ); + auditFreshInstallAndStrictConstraints(); + auditV14UpgradeChecksumAndIdempotence(); + auditPostApplyFailureRollsBack(); + auditSqlcipherV14Upgrade(); + console.log( + JSON.stringify( + { + ok: true, + suite: "vera-matter-profile-migration-audit-v15", + current_version: 15, + checks: [ + "clean SQLite v15 install", + "additive v14-to-v15 upgrade without Project backfill", + "strict enum, bounded text, canonical UTC, risk, and ordering checks", + "Project one-to-zero-or-one cardinality and delete cascade", + "profile-owned fail-closed Matter Policy defaults and normalized execution locations", + "strict egress, retention, boolean, location, and cross-Project policy checks", + "immutable ownership, immutable creation time, and monotonic update time", + "ordered checksum verification and idempotent rerun", + "post-DDL migration-record failure rolls back atomically", + "encrypted SQLCipher v14-to-v15 upgrade", + ], + }, + null, + 2, + ), + ); +} finally { + process.env = originalEnvironment; + rmSync(root, { recursive: true, force: true }); +} diff --git a/backend/src/scripts/veraWorkspaceAssistantAudit.ts b/backend/src/scripts/veraWorkspaceAssistantAudit.ts index 6575c13..ffc6d21 100644 --- a/backend/src/scripts/veraWorkspaceAssistantAudit.ts +++ b/backend/src/scripts/veraWorkspaceAssistantAudit.ts @@ -1019,7 +1019,7 @@ async function run() { ); const currentMigration = database.runMigrations(WORKSPACE_MIGRATIONS); - assert.equal(currentMigration.currentVersion, 14); + assert.equal(currentMigration.currentVersion, 15); markProfileReady(database, profileId); const projects = new ProjectsRepository(database); @@ -2842,7 +2842,7 @@ async function run() { migrations: WORKSPACE_MIGRATIONS, }); try { - assert.equal(reopened.migration?.currentVersion, 14); + assert.equal(reopened.migration?.currentVersion, 15); assert.equal( reopened .prepare("SELECT value FROM assistant_legacy_sentinel WHERE id=1") diff --git a/backend/src/scripts/veraWorkspaceAssistantDurableAudit.ts b/backend/src/scripts/veraWorkspaceAssistantDurableAudit.ts index f3378d9..cab039f 100644 --- a/backend/src/scripts/veraWorkspaceAssistantDurableAudit.ts +++ b/backend/src/scripts/veraWorkspaceAssistantDurableAudit.ts @@ -321,7 +321,7 @@ async function terminalUpgradeReplay(root: string) { const upgraded = new WorkspaceDatabase(databasePath); try { - assert.equal(upgraded.migration?.currentVersion, 14); + assert.equal(upgraded.migration?.currentVersion, 15); const replay = new ChatsRepository(upgraded).listGenerationEvents(jobId); assert.equal(replay.terminal, true); assert.deepEqual( @@ -452,7 +452,7 @@ async function run() { let database: WorkspaceDatabase | null = new WorkspaceDatabase(databasePath); let observer: WorkspaceDatabase | null = null; try { - assert.equal(database.migration?.currentVersion, 14); + assert.equal(database.migration?.currentVersion, 15); const profileId = seedEnabledProfile(database); const activeControllers = new Map(); const setup = createServices(database, { diff --git a/backend/src/scripts/veraWorkspaceDocumentStudioSuggestionsAudit.ts b/backend/src/scripts/veraWorkspaceDocumentStudioSuggestionsAudit.ts index eca3fb9..a81a409 100644 --- a/backend/src/scripts/veraWorkspaceDocumentStudioSuggestionsAudit.ts +++ b/backend/src/scripts/veraWorkspaceDocumentStudioSuggestionsAudit.ts @@ -284,7 +284,7 @@ async function run() { try { process.env.ALETHEIA_DATABASE_ENCRYPTION = "metadata_plaintext"; database = new WorkspaceDatabase(databasePath); - assert.equal(database.migration?.currentVersion, 14); + assert.equal(database.migration?.currentVersion, 15); const projects = new ProjectsRepository(database); for (const [id, name] of [ [PROJECT_ID, "Suggestion Project"], diff --git a/backend/src/scripts/veraWorkspaceMigrationAudit.ts b/backend/src/scripts/veraWorkspaceMigrationAudit.ts index df4480e..9bfc7eb 100644 --- a/backend/src/scripts/veraWorkspaceMigrationAudit.ts +++ b/backend/src/scripts/veraWorkspaceMigrationAudit.ts @@ -25,6 +25,7 @@ import { WORKSPACE_MIGRATIONS, WORKSPACE_RUNTIME_MIGRATION, DOCUMENT_STUDIO_SUGGESTIONS_V14_MIGRATION, + MATTER_PROFILES_V15_MIGRATION, type WorkspaceDatabaseAdapter, type WorkspaceMigration, } from "../lib/workspace/migrations"; @@ -64,12 +65,13 @@ const DEFAULT_V1_TO_V10 = [ MODEL_CONNECTION_READINESS_V9_MIGRATION, ASSISTANT_DURABLE_EVENTS_V10_MIGRATION, ] as const; -const DEFAULT_V1_TO_V14 = [ +const DEFAULT_V1_TO_V15 = [ ...DEFAULT_V1_TO_V10, PROJECT_SOURCE_FOUNDATION_V11_MIGRATION, DOCUMENT_STUDIO_V12_MIGRATION, SOURCE_RETENTION_LIFECYCLE_V13_MIGRATION, DOCUMENT_STUDIO_SUGGESTIONS_V14_MIGRATION, + MATTER_PROFILES_V15_MIGRATION, ] as const; const V9_PREFIX = DEFAULT_V1_TO_V10.slice(0, -1); @@ -539,10 +541,10 @@ try { process.env.ALETHEIA_DATABASE_ENCRYPTION = "metadata_plaintext"; assert.deepEqual( WORKSPACE_MIGRATIONS.map((migration) => migration.version), - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], - "the default registry is a contiguous v1-v14 chain", + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + "the default registry is a contiguous v1-v15 chain", ); - assert.deepEqual(WORKSPACE_MIGRATIONS, DEFAULT_V1_TO_V14); + assert.deepEqual(WORKSPACE_MIGRATIONS, DEFAULT_V1_TO_V15); const upgradePath = path.join(root, "upgrade.db"); const v1Database = createUnmigratedDatabase("upgrade.db"); @@ -1415,11 +1417,11 @@ try { const upgraded = new WorkspaceDatabase(databasePath); try { - assert.equal(upgraded.migration?.currentVersion, 14); + assert.equal(upgraded.migration?.currentVersion, 15); assert.deepEqual( upgraded.migration?.applied.map((record) => record.version), Array.from( - { length: 14 - prefixVersion }, + { length: 15 - prefixVersion }, (_, index) => prefixVersion + index + 1, ), ); @@ -1482,9 +1484,9 @@ try { fts5: true, sqlcipherEncrypted: false, }); - assertMigrationRecords(upgraded, DEFAULT_V1_TO_V14); + assertMigrationRecords(upgraded, DEFAULT_V1_TO_V15); const rerun = upgraded.runMigrations(); - assert.equal(rerun.currentVersion, 14); + assert.equal(rerun.currentVersion, 15); assert.deepEqual(rerun.applied, []); if (prefixVersion === 6) { const driftedV8: WorkspaceMigration = { @@ -1502,19 +1504,20 @@ try { DOCUMENT_STUDIO_V12_MIGRATION, SOURCE_RETENTION_LIFECYCLE_V13_MIGRATION, DOCUMENT_STUDIO_SUGGESTIONS_V14_MIGRATION, + MATTER_PROFILES_V15_MIGRATION, ]), /checksum drift/i, ); - assertMigrationRecords(upgraded, DEFAULT_V1_TO_V14); + assertMigrationRecords(upgraded, DEFAULT_V1_TO_V15); } } finally { upgraded.close(); } const reopened = new WorkspaceDatabase(databasePath); try { - assert.equal(reopened.migration?.currentVersion, 14); + assert.equal(reopened.migration?.currentVersion, 15); assert.deepEqual(reopened.migration?.applied, []); - assertMigrationRecords(reopened, DEFAULT_V1_TO_V14); + assertMigrationRecords(reopened, DEFAULT_V1_TO_V15); assert.equal( reopened .prepare("SELECT payload FROM aletheia_phase1_sentinel WHERE id = 1") @@ -1529,10 +1532,10 @@ try { const newInstall = createUnmigratedDatabase("new-install.db"); try { const migration = runWorkspaceMigrations(newInstall, WORKSPACE_MIGRATIONS); - assert.equal(migration.currentVersion, 14); + assert.equal(migration.currentVersion, 15); assert.deepEqual( migration.applied.map((record) => record.version), - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], ); assert.ok(schemaNames(newInstall, "table").has("workspace_blob_records")); assert.ok( @@ -1579,7 +1582,12 @@ try { "connection_revision", ], ); - assertMigrationRecords(newInstall, DEFAULT_V1_TO_V14); + assert.ok(schemaNames(newInstall, "table").has("matter_profiles")); + assert.ok(schemaNames(newInstall, "table").has("matter_policies")); + assert.ok( + schemaNames(newInstall, "table").has("matter_policy_execution_locations"), + ); + assertMigrationRecords(newInstall, DEFAULT_V1_TO_V15); } finally { newInstall.close(); } @@ -1800,7 +1808,7 @@ try { ); const encryptedGateUpgrade = new WorkspaceDatabase(plaintextGatePath); try { - assert.equal(encryptedGateUpgrade.migration?.currentVersion, 14); + assert.equal(encryptedGateUpgrade.migration?.currentVersion, 15); assert.equal( encryptedGateUpgrade.migration?.capabilities.sqlcipherEncrypted, true, @@ -1832,7 +1840,7 @@ try { .get()?.payload, "legacy-data-must-survive", ); - assertMigrationRecords(encryptedGateUpgrade, DEFAULT_V1_TO_V14); + assertMigrationRecords(encryptedGateUpgrade, DEFAULT_V1_TO_V15); } finally { encryptedGateUpgrade.close(); } @@ -1880,12 +1888,12 @@ try { encryptedDatabase.migration?.capabilities.sqlcipherEncrypted, true, ); - assert.equal(encryptedDatabase.migration?.currentVersion, 14); + assert.equal(encryptedDatabase.migration?.currentVersion, 15); assert.equal( encryptedDatabase .prepare("SELECT count(*) AS count FROM workspace_schema_migrations") .get()?.count, - 14, + 15, ); } finally { encryptedDatabase.close(); @@ -1912,12 +1920,12 @@ try { true, "the wrapper-owned migration entrypoint preserves exact SQLCipher attestation", ); - assert.equal(encryptedManualRun.currentVersion, 14); + assert.equal(encryptedManualRun.currentVersion, 15); assert.equal( encryptedManualDatabase .prepare("SELECT count(*) AS count FROM workspace_schema_migrations") .get()?.count, - 14, + 15, ); } finally { encryptedManualDatabase.close(); @@ -1931,19 +1939,20 @@ try { JSON.stringify( { ok: true, - suite: "vera-workspace-migration-audit-v14", - current_version: 14, + suite: "vera-workspace-migration-audit-v15", + current_version: 15, encrypted_driver: encryptedStatus!.encrypted, checks: [ "legacy opaque and incomplete-matrix fixtures remain explicit v1-v6", - "runtime-valid v1, v2, v3, and v6 prefixes upgrade through default v14", - "clean default v14 install", + "runtime-valid v1, v2, v3, and v6 prefixes upgrade through default v15", + "clean default v15 install", "v9 connection readiness schema applies on SQLite and SQLCipher", "v10 immutable Assistant durable event outbox applies on SQLite and SQLCipher", "v11 immutable Project source snapshots and citation anchors", "v12 Project Document Studio metadata and citation bindings", "v13 source retention lifecycle and fail-closed access metadata", "v14 immutable Document Studio AI suggestions", + "v15 optional Matter Profiles and fail-closed Matter Policies", "ordered SHA-256 checksums and idempotent rerun", "failed migration DDL and record roll back atomically", "legacy Aletheia sentinel table and row preserved", @@ -1961,7 +1970,7 @@ try { "cross-project, ownership, cycle, type, and cell triggers", "SQLite and SQLCipher integrity checks", "plaintext destructive migration fails byte-exact before SQLCipher", - "offline SQLCipher migration enables trusted default v7-v14 upgrade", + "offline SQLCipher migration enables trusted default v7-v15 upgrade", "encrypted v6 missing matrix fails transactionally before v7 markers", "encrypted v6 opaque tabular IDs fail the frozen validator transactionally", ], diff --git a/backend/src/scripts/veraWorkspaceModelConnectionReadinessAudit.ts b/backend/src/scripts/veraWorkspaceModelConnectionReadinessAudit.ts index ace1870..a07404b 100644 --- a/backend/src/scripts/veraWorkspaceModelConnectionReadinessAudit.ts +++ b/backend/src/scripts/veraWorkspaceModelConnectionReadinessAudit.ts @@ -189,10 +189,10 @@ function auditUpgradeAndConnectionRevisionSemantics() { const upgraded = new WorkspaceDatabase(databasePath); try { - assert.equal(upgraded.migration?.currentVersion, 14); + assert.equal(upgraded.migration?.currentVersion, 15); assert.deepEqual( upgraded.migration?.applied.map((entry) => entry.version), - [9, 10, 11, 12, 13, 14], + [9, 10, 11, 12, 13, 14, 15], ); assert.equal( columnNames(upgraded, "model_profiles").includes("connection_revision"), @@ -377,10 +377,10 @@ function insertConnectionResult( function auditNewInstallAndStrictConstraints() { const database = new WorkspaceDatabase(path.join(root, "new-install.db")); try { - assert.equal(database.migration?.currentVersion, 14); + assert.equal(database.migration?.currentVersion, 15); assert.deepEqual( database.migration?.applied.map((entry) => entry.version), - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], ); assert.equal( WORKSPACE_MIGRATIONS.at(8), @@ -719,7 +719,7 @@ try { process.env.ALETHEIA_DATABASE_ENCRYPTION = "metadata_plaintext"; assert.deepEqual( WORKSPACE_MIGRATIONS.map((migration) => migration.version), - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], ); assert.deepEqual( V8_MIGRATIONS.map((migration) => migration.version), diff --git a/backend/src/scripts/veraWorkspaceModelSettingsAudit.ts b/backend/src/scripts/veraWorkspaceModelSettingsAudit.ts index 7d363a0..7b221e3 100644 --- a/backend/src/scripts/veraWorkspaceModelSettingsAudit.ts +++ b/backend/src/scripts/veraWorkspaceModelSettingsAudit.ts @@ -4257,7 +4257,7 @@ try { checksumMaterial: migration.checksumMaterial, })); assert.deepEqual(compiledGraph.migrations, currentChecksums); - assert.equal(compiledGraph.runtime.currentVersion, 14); + assert.equal(compiledGraph.runtime.currentVersion, 15); assert.deepEqual( compiledGraph.runtime.appliedVersions, WORKSPACE_MIGRATIONS.map((migration) => migration.version), diff --git a/docs/architecture.md b/docs/architecture.md index afaf862..253101b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,7 +35,7 @@ Vera.app (Electron main process) `- controlled native backup/restore/log/diagnostic operations Local persistence - |- SQLCipher Workspace metadata (migrations v1-v14) + |- SQLCipher Workspace metadata (migrations v1-v15) |- AES-256-GCM encrypted originals, extracted content, and exports |- FTS5 project/document retrieval |- authenticated encrypted backups and restore journal @@ -51,9 +51,10 @@ state persistence. `Project` is the general-purpose ownership and context boundary. A Project can own folders, documents and versions, chats/messages, and Tabular Reviews/cells, -and it scopes runs of reusable global workflow definitions. Project ownership -checks are enforced in the repository/service layer; UI tabs do not create -separate storage silos. +and it scopes runs of reusable global workflow definitions. An optional, +one-to-one `MatterProfile` adds legal intake semantics without renaming or +splitting that boundary. Project ownership checks are enforced in the +repository/service layer; UI tabs do not create separate storage silos. The canonical new product API is mounted once at `/api/v1` by `backend/src/veraApplication.ts`: @@ -81,9 +82,10 @@ process, which binds each item to the model profile, provider, and canonical origin in the macOS Keychain. Provider adapters perform the only credential resolution and external network calls. -Legacy `/aletheia/*` routes and repositories remain mounted for compatibility -and regression. They are outside the P0 main navigation and do not replace the -Workspace `/api/v1` product path. +Legacy `/aletheia/*` source and resources remain available for controlled +compatibility and regression, but the routes and background runtime are both +off by default. Only exact, explicit feature flags mount or start them; they do +not replace the Workspace `/api/v1` product path. ## Packaged P0 acceptance diff --git a/docs/convergence/phase-2a-matter-foundation-migration.md b/docs/convergence/phase-2a-matter-foundation-migration.md new file mode 100644 index 0000000..19386f7 --- /dev/null +++ b/docs/convergence/phase-2a-matter-foundation-migration.md @@ -0,0 +1,89 @@ +# Phase 2A — Matter Foundation Migration + +Date: 2026-07-16 + +## Completed + +Workspace migration v15 adds an optional, one-to-one legal extension to the +existing Project ownership boundary. It does not rename `projects`, backfill +legal meaning, or create another database. + +```text +Project 1 --- 0..1 MatterProfile +MatterProfile 1 --- 0..1 MatterPolicy +MatterPolicy 1 --- 0..* allowed execution locations +``` + +An ordinary Project remains valid with no profile. Missing policy and an empty +execution-location set are both deny-all; no local or remote inference is +silently authorized. + +## Files changed + +- additive v15 migration and registry entry; +- focused Matter-foundation migration audit; +- existing full Workspace migration audit expectations; +- active architecture/schema documentation; +- this migration and rollback record. + +## Migrations added + +Migration v15, `project_matter_foundation`, creates: + +- `matter_profiles`; +- `matter_policies`; +- `matter_policy_execution_locations`; +- bounded lookup indexes and ownership/update guards. + +The migration is transactional, checksum-recorded and idempotent. It upgrades +the existing v14 database without modifying v1-v14 migration files or changing +existing Project, document, source or Studio rows. + +## Security implications + +- Matter ownership remains the existing Project foreign-key boundary. +- Profile metadata is bounded intake data, not a store for sourced facts, + model conclusions or formal legal state. +- Matter policy defaults external egress off, external legal sources off and + Word bridge off; audio retention is unconfigured rather than guessed. +- Allowed model execution locations use normalized, enumerated rows instead of + permissive JSON. +- Invalid enums, booleans, timestamps, ownership changes and cross-owner rows + fail at the schema boundary. + +## Rollback + +No destructive down migration is provided. A binary that knows v15 can leave +the additive tables unused. A pre-v15 binary rejects the unknown migration +registry, so executable rollback requires either a compatibility build that +retains the v15 registry or restoration of the verified encrypted pre-migration +backup. Do not delete v15 rows or edit the migration ledger in place. + +## Tests + +The focused and existing migration suites cover: + +```text +clean SQLite v15 install +v14-to-v15 preservation upgrade +ordinary Project without MatterProfile +one-to-one ownership and delete cascade +strict profile and policy constraints +deny-all policy defaults and normalized execution locations +checksum drift, idempotent rerun and injected rollback +encrypted SQLCipher v14-to-v15 upgrade +backend build +``` + +## Known limitations + +- This commit provides persistence only; no Matter repository, service, route + or renderer uses the new tables yet. +- A missing policy is deliberately unavailable, not an implicit default row. +- Artifact, Review, Work Queue and Conversation state remain later migrations. + +## Next phase + +Phase 2B adds the Matter Profile repository/service/API and atomically creates +a Project plus MatterProfile. It remains a separate commit from this migration +and from the Phase 2C navigation work. diff --git a/docs/desktop_app.md b/docs/desktop_app.md index 049e2b5..3a0bee5 100644 --- a/docs/desktop_app.md +++ b/docs/desktop_app.md @@ -136,10 +136,11 @@ credentials, proxy variables, or Node injection flags. ## Local persistence and credentials -Workspace schema migrations currently run through v14 -(`v14DocumentStudioSuggestions`). V11 adds the Project source foundation, v12 -adds Document Studio, v13 adds source-retention lifecycle enforcement, and v14 -adds reviewable Document Studio suggestions. Projects, folders, document +Workspace schema migrations currently run through v15 +(`v15MatterProfiles`). V11 adds the Project source foundation, v12 adds +Document Studio, v13 adds source-retention lifecycle enforcement, v14 adds +reviewable Document Studio suggestions, and v15 adds the optional one-to-one +Matter Profile plus fail-closed Matter Policy foundation. Projects, folders, document versions, chats, messages, jobs, workflow definitions/runs/step runs, Tabular Reviews/cells, model-profile metadata, durable Assistant events, source provenance, and Studio state use the local Workspace database. The packaged diff --git a/docs/mike_port_manifest.md b/docs/mike_port_manifest.md index b9e649d..f4626ed 100644 --- a/docs/mike_port_manifest.md +++ b/docs/mike_port_manifest.md @@ -59,7 +59,7 @@ Mike UI 的使用已经获得授权。仓库仍按 AGPL-3.0-only 基线保留来 ## 2. 当前工作树事实快照 - 固定 Mike commit 已在本地 Git 对象库中,移植文件保留 source-lock/provenance。 -- `backend/src/lib/workspace/` 已收敛到 additive SQLCipher migrations v1-v14、repositories/services、加密 Blob、下载 capability、FTS、持久 jobs/events 和统一 runtime。 +- `backend/src/lib/workspace/` 已收敛到 additive SQLCipher migrations v1-v15、repositories/services、加密 Blob、下载 capability、FTS、持久 jobs/events 和统一 runtime;v15 以一对一可选扩展保留 Project 技术边界。 - `backend/src/index.ts` 是薄入口;`backend/src/veraApplication.ts` 是唯一 composition root。Legacy `/aletheia` 保留,Workspace API 只在 `/api/v1` 挂载一次。 - 同一个持久 job pump 执行 `document_parse`、`assistant_generate`、`workflow_run` 和 `tabular_cell`;没有第二套前端假执行器或内存任务状态机。 - Assistant、Projects、Tabular Review、Workflows、Settings 的活动页面与真实本地 API 已接通;主导航不再进入 `/aletheia/*`。 @@ -152,7 +152,7 @@ Mike UI 的使用已经获得授权。仓库仍按 AGPL-3.0-only 基线保留来 | renderer transport | `veraRuntime.ts`、`veraApi.ts`、`veraSse.ts`、`veraWireTypes.ts`;token 由 preload 提供 | `source-complete` | | preload | 只暴露必要 token、backup/restore、日志、凭据和受控 native 能力;`window.aletheiaDesktop` 名称仅作为 Legacy 兼容边界 | `source-complete; packaged-complete` | | downloads | `/api/v1/downloads/:token` + 短期 capability | `source-complete; packaged-complete` | -| schema | additive SQLCipher migrations v1-v14;不导入 Mike Postgres/RLS schema | `source-complete` | +| schema | additive SQLCipher migrations v1-v15;不导入 Mike Postgres/RLS schema | `source-complete` | ## 10. 明确排除的云端和多用户依赖 From 2021d8f4518faf96927ff03a22cb6d0e78101b4a Mon Sep 17 00:00:00 2001 From: shawliu998 <260819717+shawliu998@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:54:06 +0800 Subject: [PATCH 04/10] docs: rebaseline legal workspace roadmap --- docs/adr/vera-product-convergence.md | 482 ++++++++++++++++++++ docs/convergence/current-state-audit.md | 7 + docs/convergence/data-migration-plan.md | 8 +- docs/convergence/open-source-reuse-plan.md | 5 + docs/convergence/target-architecture.md | 8 +- docs/reuse_decisions.md | 507 +++++++++++++++++++++ docs/roadmap_legal_workspace.md | 373 +++++++++++++++ docs/vera_legora_harvey_gap_analysis.md | 247 ++++++++++ 8 files changed, 1635 insertions(+), 2 deletions(-) create mode 100644 docs/adr/vera-product-convergence.md create mode 100644 docs/reuse_decisions.md create mode 100644 docs/roadmap_legal_workspace.md create mode 100644 docs/vera_legora_harvey_gap_analysis.md diff --git a/docs/adr/vera-product-convergence.md b/docs/adr/vera-product-convergence.md new file mode 100644 index 0000000..4c6947f --- /dev/null +++ b/docs/adr/vera-product-convergence.md @@ -0,0 +1,482 @@ +# ADR — Vera Product Convergence + +Date: 2026-07-16 + +Status: Accepted — Gate 0 + +Decision scope: Product, domain ownership, runtime, API, data, deployment, +information architecture, compatibility, migration, rollback, and security + +Recorded baseline: `feat/legal-matter-agent-convergence` at `408333d7` + +## 1. Authority and supersession + +This document is the sole forward architecture decision record for the Vera +product convergence. Later implementation plans must conform to it or replace +it through a new, explicitly accepted ADR. + +Earlier documents under `docs/convergence/` are retained as historical audit +evidence, design exploration, migration notes, and provenance records. A short +status notice may identify their historical role, but their original decisions +are not deleted or rewritten to make history look current. Their factual +evidence and license obligations remain useful, but any prescriptive decision +that conflicts with this ADR is superseded. + +In particular, this ADR supersedes the earlier primary navigation proposal: + +```text +Matters | Work Queue | Workflows | Assistant | Settings +``` + +and the earlier Matter navigation proposal: + +```text +Overview | Sources | Case Map | Work | Activity +``` + +`Work Queue`, `Case Map`, `Work`, and `Activity` are not primary navigation +destinations in the accepted information architecture. Their underlying domain +concepts may still exist or be introduced behind the accepted surfaces, but an +implementation must not restore those former navigation labels without a new +ADR. + +This ADR also supersedes the former Artifact-first delivery order. Vera ships a +small Proposal-and-Review contract first, beginning with adapters to existing +authoritative owners such as Document Studio. A formal Fact, Issue, Task, or +other legal-state type is introduced only with its own schema, source, +promotion, and rollback contract; a generic Artifact graph is not a prerequisite +for the first useful reviewed workflow. + +## 2. Context + +The repository currently combines a secure local Vera Workspace product, +controlled Mike-derived UI and compatibility surfaces, and an isolated Legacy +Aletheia system. Convergence must produce one coherent legal product without a +second frontend, database, document store, model configuration system, job +scheduler, or active Legacy application. + +The product loop is: + +```text +Source + -> immutable Source Snapshot and Citation Anchor + -> bounded Assistant, Workflow, OCR, Tabular, or other processor output + -> candidate or proposal + -> unified Review + -> lawyer decision + -> authoritative owner mutation, Draft revision, or later formal Matter state + -> audit and evaluation +``` + +Chat history and model output are not authoritative legal memory. The durable +legal record consists of source-linked, reviewed state and immutable revisions. + +## 3. Singular product decisions + +Vera has exactly one entry for each foundational concern: + +| Concern | Accepted decision | Consequence | +| ----------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| Product | One Vera desktop product and one shared shell | Legacy is not a second user-facing application and no parallel Matter app is introduced. | +| Container | One technical ownership container: `projects.id` | A Matter is a Project with an explicit one-to-zero-or-one MatterProfile. Existing Project foreign keys remain authoritative. | +| Source | One source, snapshot, anchor, and encrypted-blob model | Documents, later email/notes/conversations, and controlled legal sources extend the same provenance model rather than creating source silos. | +| Review | One unified Proposal, Review, and resolution service | AI and automation create candidates. Only an explicit lawyer decision can resolve a Proposal or authorize its authoritative owner mutation. | +| Job | One durable SQLite job store and pump | Assistant, Workflow, parsing, OCR, future agents, and integrations reuse the existing recoverable job control plane and domain-specific handlers. | +| Model entry | One model-profile registry, Settings surface, inference gateway, and credential path | A run pins one profile and policy. Features cannot embed their own provider clients, credentials, model settings, or shadow gateways. | + +These are invariants, not temporary implementation preferences. + +## 4. Canonical container and state rules + +1. `Project` remains the technical boundary for ownership, backup, documents, + Assistant scope, Workflow runs, and isolation checks. +2. `MatterProfile` adds legal semantics without renaming or duplicating the + Project graph. A generic Project remains valid. +3. Creating a Matter atomically creates its Project and MatterProfile. Adding a + profile to an existing Project always requires an explicit user action. +4. No UI label, migration, or background job may silently infer a MatterProfile + from a Project name, document, chat, or Legacy record. +5. A legal fact, issue, evidence item, position, decision, instruction, or task + becomes durable Matter state only after that type has a formal owner and a + reviewed promotion contract. It is never made authoritative by a chat + message or a generic JSON label. +6. Proposal disposition is exactly `open`, `accepted`, `modified`, `rejected`, + or `superseded`, is recorded explicitly, and is never inferred from model + confidence. Type-specific resolution actions do not become extra lifecycle + states. +7. Source content is immutable at the snapshot boundary. Corrections append a + new source version or state revision; they do not overwrite the original. +8. Source links bind an exact snapshot and, when available, a validated anchor + with a content hash and typed locator. + +## 5. Module ownership + +Module boundaries are logical ownership boundaries even while extraction from +the current large Workspace runtime is incremental. A module may be composed +inside the existing runtime before it has its own top-level directory, but it +must not open another database or reverse dependency direction. + +| Owner | Owns | Must not own | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| Desktop host | Electron lifecycle, process supervision, renderer sandbox, CSP/connect policy, per-launch bearer delivery, Keychain utility process, packaged backup/restore orchestration | Legal state, model provider logic, or a second API/data plane | +| Core | SQLCipher connection and migration ledger, encrypted blob store, durable jobs, audit health, safe errors, lifecycle/draining, backup primitives | Product navigation or domain-specific legal decisions | +| Workspace | Projects, folders, documents and versions, source records, Source Snapshots, Citation Anchors, Assistant chats, Workflow definitions/runs, Tabular processing, Document Studio, model-profile configuration | Accepted Matter semantics or a competing Review boundary | +| Matter | MatterProfile, the unified Proposal/Review/Resolution contract, later explicitly owned formal legal-state types, derived work items, stale/validation state, and Matter projections | Raw source copies, provider credentials, a speculative generic Artifact graph, or an independent scheduler | +| Conversations | Imported/captured sessions, participants, immutable machine transcript layers, reviewed transcript layers, speaker binding, extraction candidates | A separate source/anchor model or direct accepted Matter writes | +| Inference and Tool Broker | Model invocation policy, typed tool registry, egress decisions, legal-source adapters, later Word bridge policy, approval enforcement | Model profiles, raw provider secrets, arbitrary shell/path/URL tools, or authoritative Matter state | +| Presentation | The accepted shell, routes, accessibility, i18n, truthful capability states, and API clients | Persistence, authorization, promotion decisions, or fabricated availability | +| Legacy adapters | Read-only compatibility and bounded migration into active owners | Calls from active modules, active product state, default startup, or new feature development | + +Review may be implemented as a bounded package inside the Matter module, but it +remains one service and one resolution boundary. A global Review view and a +Matter-scoped Review view are projections over the same Proposal and resolution +records, not separate review systems. Review references authoritative Studio, +OCR, Workflow, Tabular, Assistant, or later Matter payloads; it does not clone +those payloads into a second owner. + +## 6. One runtime, API, and database + +### 6.1 Runtime + +Vera keeps one supervised desktop runtime graph: + +```text +Electron main + |-- sandboxed Next.js renderer + |-- one loopback Express/TypeScript backend + | `-- one composed Core + Workspace + Matter + broker graph + `-- isolated Keychain credential utility process +``` + +The current Workspace runtime may host newly extracted modules during +convergence. Module factories receive narrow ports and the already-open +database adapter. They do not create another application runtime or database +connection owner. + +Legacy routes and runtime are disabled by default and independently gated. +Active modules never import a Legacy router, repository, scheduler, or table. +Legacy may run only for an explicitly authorized compatibility test or +migration operation. + +### 6.2 API + +There is one authenticated, audit-guarded active API root: + +```text +/api/v1 +``` + +All active modules mount additive routers under that single composition root +and inherit the same loopback authentication, mutation guard, limits, draining +behavior, cache policy, and safe error envelope. There is no parallel `/api/v2` +or module-specific server. + +Existing Project, document, Assistant, Workflow, Tabular, source, Studio, +settings, and model-profile contracts remain compatible. Matter and later +resource families are additive, including: + +```text +/api/v1/matters +/api/v1/projects/:projectId/matter-profile +``` + +Gate 2 introduces one Proposal/Review resource family beneath `/api/v1`; its +global and Project-scoped projections use the same owner and records. Exact +route names land with that schema and contract rather than being guessed in +Gate 0. Public contracts are strict, bounded, versioned by additive evolution, +and free of credentials, absolute paths, raw internal errors, and unbounded +source content. + +`/aletheia/*` is not an active API namespace. It remains unavailable unless an +explicit Legacy route flag is enabled for a bounded compatibility or migration +purpose. New UI must never depend on it. + +### 6.3 Database and blobs + +There is one Workspace SQLCipher database, one ordered checksum-recorded +migration ledger, and one transaction coordinator. Domain modules own their +tables but share that connection and Project ownership key. + +There is one encrypted blob store for original files, source bodies, audio, +previews, and derived file payloads that require blob storage. Metadata does +not contain local absolute paths. Provider credentials and database encryption +keys remain outside the database in the macOS Keychain path owned by the +desktop credential process. + +No graph database, vector database, second SQLite file, browser persistence, +or external service becomes an authoritative Vera state store without a new +ADR. + +## 7. Deployment boundaries + +The supported product boundary is the packaged local desktop application. It +contains the renderer, loopback backend, Core/Workspace/Matter modules, and +supervised utility processes. It must remain useful for local data operations +without a hosted Vera control plane. + +External model providers are optional execution dependencies reached only +through the approved inference gateway after model readiness, credential, +source policy, retention, and egress checks. A provider is never a persistence +or authorization authority. + +Additional processes are permitted only when the boundary is narrower than the +main backend: + +- a capture sidecar may own audio devices, durable partial-file recovery, VAD, + and ASR adapter events, but no Matter state, credentials, Review decision, or + database; +- a Word Add-in may pair with a separately approved loopback bridge using + short-lived, document-scoped authority, but receives no provider credential, + database access, arbitrary path, or arbitrary URL; +- the existing Keychain utility process may perform bounded secret operations + and returns only the minimum status or secret channel required by its host. + +Docker, test servers, or developer launchers may package the same backend for +testing, but they do not define another production topology. Relaxing +loopback-only binding or introducing a hosted authoritative service requires a +new security ADR. + +## 8. Accepted information architecture + +### 8.1 Top level + +The primary navigation, in order, is: + +```text +Assistant | Matters | Workflows | Review | Settings +``` + +- **Assistant** is the entry for unassigned work and existing global + conversations. A Matter Assistant binds its Project context by default. +- **Matters** is the legal workspace index. It presents explicit MatterProfile + state while retaining truthful handling of generic Projects. +- **Workflows** contains reusable definitions; a Matter-scoped view supplies + the Project run context. +- **Review** is the unified cross-Matter Proposal and Review Center. It is not + Tabular Review and is interactive only after the real Proposal persistence, + Review API, and resolution boundary exist. +- **Settings** remains capability-gated by the local settings runtime. + +Until another accepted decision changes the home behavior, `/` and the Vera +product mark continue to land on `/assistant`. Navigation order does not +silently change desktop startup behavior. + +The top-level Projects and Tabular entries are removed when Matters ships, but +their compatible routes are retained. An unavailable Review capability is +shown as disabled with explicit text; Vera does not mount a static page that +pretends Review data exists. + +### 8.2 Matter + +The Matter navigation, in order, is: + +```text +Overview | Documents | Assistant | Review | Workflows | Drafts +``` + +- **Overview** presents real MatterProfile and Project summaries only. +- **Documents** reuses the Project document/version/source-viewer capability. + Broader source kinds extend the shared source model rather than restoring a + generic `Sources` primary tab without an implemented surface. +- **Assistant** is Project-scoped and uses the same chat and job services as + the global Assistant. +- **Review** is the Matter-filtered projection of the one unified Review + service. +- **Workflows** reuses global Workflow definitions with the Matter's Project + as an explicit run container. +- **Drafts** presents Document Studio work product and immutable versions. It + does not create a second editor or document repository. + +Tabular Review remains a real processing capability and compatible deep link. +It is not renamed to unified Review, and its current `review_count` is never +displayed as a Review Inbox count. Tabular output may create Review candidates +only after the unified Review adapter exists. + +Work items remain a Matter-owned domain concept but do not justify a top-level +Work Queue. Artifact graphs, work summaries, and activity projections may be +presented within Overview, Review, Workflows, or contextual detail views. They +do not revive the superseded `Case Map | Work | Activity` primary tabs. +If a queue projection is introduced, it is a secondary mode within Review and +reuses Proposal, durable Job, OCR, Workflow, and explicitly user-created task +state rather than creating another state machine. + +All new copy uses the existing Chinese-first i18n system. Status, conflict, +staleness, confidence, and review disposition are conveyed through text or +icons in addition to color. + +## 9. Compatibility and migration + +### 9.1 Active compatibility + +- Existing `/api/v1/projects` and Project-owned resource contracts remain + valid. +- Exact `/projects` UI navigation may redirect to `/matters` after the Matters + list is available; `/projects/:id/**` deep links remain valid during the + compatibility window. +- Existing Assistant, Workflow, Tabular Review, document viewer, citation + viewer, and Document Studio routes remain usable. +- Mike-derived controlled files retain their fixed provenance comments, + reviewed source pin, license obligations, and source-level tests. +- A generic Project with `matter_profile: null` remains visible and usable. It + is never silently upgraded to a Matter. +- Old persisted chats, jobs, Workflow runs, Tabular results, Studio versions, + snapshots, and anchors remain readable through additive migrations. + +Compatibility does not permit two meanings for one label. `Review` always +means unified human review; `Tabular Review` keeps its explicit name until its +output enters unified Review through a real adapter. + +### 9.2 Schema and Legacy migration + +All active migrations are additive, ordered, checksum-recorded, +transactional, and tested on SQLite development fixtures and packaged SQLCipher +paths. Existing migration files and checksums are immutable. Destructive table +renames, bulk inferred profile creation, and in-place deletion are prohibited. + +Committed migration v15 remains immutable. Its litigation-oriented +`matter_type` and dormant Matter Policy foundation are transitional storage, +not the final public workspace taxonomy and not proof that inference is +permitted. A later additive classification migration introduces the broader +user-selected workspace classification and jurisdiction without guessing from +v15 values. Missing classification remains an explicit capability state. + +Legacy migration is a bounded import, not runtime convergence: + +1. create and verify an encrypted pre-migration backup; +2. run a read-only preflight and deterministic mapping report; +3. preserve each Legacy type and ID in a migration ledger; +4. migrate in bounded, restartable transactions; +5. record conflicts and unmigratable objects without guessing; +6. verify target ownership, source links, revisions, and Review disposition; +7. leave Legacy source data intact and read-only until later deletion gates. + +Active modules may expose narrow migration ports. They may not call Legacy +repositories or reuse Legacy tables as live state. Legacy adapters depend on +active module ports; dependency direction never reverses. + +## 10. Rollback and recovery + +Each schema, backend, frontend, desktop, native sidecar, Word, and Legacy +migration change lands as a separate, runnable, reversible commit when more +than one boundary is involved. + +Rollback follows these rules: + +- a frontend IA commit can be reverted while compatible Project routes remain; +- an additive API module can be unmounted without deleting its persisted + tables; +- a compatibility binary that recognizes the installed migration ledger may + run with unused additive tables; +- a binary that does not recognize the database's migration ledger must fail + closed and requires the verified pre-migration encrypted backup; +- no rollback drops selected tables, rewrites the migration ledger, deletes + successful migrated records, or overwrites the current workspace in place; +- new job producers are disabled and the durable pump is drained before a + runtime rollback; +- partial Legacy imports resume from their migration-item ledger or restore the + whole verified backup. They are not repaired by deleting successful target + objects. + +Legacy routes, runtime, tables, blobs, and source files are deleted only after +compatibility, migration, restore, security, and packaged-release gates pass +and a separate deletion decision is accepted. + +## 11. Security decisions + +1. The backend binds validated loopback literals only. Production trust-proxy + behavior cannot widen the client boundary. +2. Every `/api/v1` request crosses the same per-launch bearer authentication. + Project ownership is rechecked in services and repositories, not only in + middleware or UI routes. +3. The renderer remains sandboxed and receives no database key, provider + secret, unrestricted filesystem access, or local absolute path. +4. Packaged storage requires SQLCipher, encrypted blobs, macOS Keychain, and + verified backup/restore. A plaintext downgrade fails closed. +5. Audit-health failure blocks mutations. A service cannot report success + before its bounded transaction and audit event complete. +6. Public inputs and outputs are exact, bounded, and validated. Logs, health, + errors, diagnostics, and migration reports redact secrets, raw provider + failures, absolute paths, unbounded source text, and raw audio. +7. AI, OCR, Assistant, Workflow, Tabular, transcript, and external-source + output is candidate state by default. The model cannot manufacture a human + decision or accepted disposition. +8. Review resolution re-reads the authoritative target and verifies ownership, + current version, source/retention state, staleness, and audit health. The + target owner's formal mutation and the Proposal resolution/audit event + commit atomically or neither commits. A Proposal is never treated as the + authoritative payload. +9. The durable job system retains append-only recovery events, immutable input + snapshots, bounded retry/cancel behavior, and the model/tool policy pinned at + planning time. +10. The inference and Tool Broker accepts typed IDs and approved operations, + never arbitrary shell commands, local paths, URLs, HTTP calls, or + unapproved external connectors. +11. Source retention and egress policy is re-evaluated at model invocation, + export, external retrieval, capture, and Word boundaries. API + accessibility alone never establishes content rights. +12. Court filing, final email sending, automatic client-instruction acceptance, + and other high-risk external actions remain unavailable without a separate + reviewed capability and recorded human approval. +13. New copied source, assets, dependencies, models, weights, and datasets keep + the repository's fixed-pin provenance, license, notice, and security review + gates. +14. The dormant v15 Matter Policy tables do not create an allow policy. Matter + inference fails closed while required policy or allowed execution locations + are absent, and a renderer or compatibility deep link cannot bypass that + backend decision. A later policy gate supplies verified model privacy + metadata and user controls; it does not retrofit permissive defaults. + +## 12. Delivery gates and truthful UI + +Gate 0 is complete only when this ADR is accepted as the forward decision. A +roadmap may sequence delivery and evidence, but it cannot redefine these +product, ownership, runtime, IA, migration, or security decisions. + +The first UI vertical slice may add the accepted top navigation, Matter list, +atomic create/profile forms, and a real Overview while linking Documents, +Assistant, and Workflows through compatible Project routes. Review remains +disabled until unified Review persistence and API transactions exist. Drafts +may continue to open through Documents until a complete, bounded Studio +collection contract supports the dedicated tab. + +Later capabilities become interactive only when their real storage, API, +authorization, recovery, error, and test contracts exist. Static cards, TODO +handlers, fixtures, guessed counts, fake success, and relabeled compatibility +features do not satisfy a gate. + +Every gate leaves the packaged desktop startable and runs its focused tests +plus affected P0/P1, encryption, backup/restore, shell, and packaging +regressions. + +## 13. Consequences and rejected alternatives + +Accepted consequences: + +- Product language becomes Matter-centric while Project remains visible in + technical routes and compatibility contracts. +- Review and Drafts navigation may initially be disabled or bridged to existing + surfaces rather than represented by placeholder data. +- Existing Workspace services are adapted behind clearer owners instead of + rewritten. +- Proposal-first Review can deliver useful, source-backed human decisions + before Vera defines every future formal legal-state type. +- Some Legacy capabilities arrive later because they must be migrated into the + shared source, Review, job, and security boundaries. +- Historical convergence documents can disagree with current direction; this + ADR resolves the disagreement without deleting the record. + +Rejected alternatives: + +| Alternative | Decision | +| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| A second Matter application, frontend, API server, or database | Rejected: it splits ownership, security, backup, and product behavior. | +| Destructive Project-to-Matter table and route rename | Rejected: Project remains the stable technical container. | +| Chat history or model output as authoritative legal memory | Rejected: durable accepted state requires source-linked Review. | +| A generic Artifact graph as a prerequisite for the first reviewed workflow | Rejected: ship bounded Proposals and authoritative-owner adapters first; add formal state types only with complete contracts. | +| Tabular Review or Studio suggestion acceptance as a separately branded unified Review Inbox | Rejected: adapters must converge on one Review service without lying about current semantics. | +| Separate agent scheduler, queue, or event store | Rejected: reuse the durable Workspace job control plane. | +| Feature-specific provider SDKs, credentials, or model selectors | Rejected: use the one model entry and broker policy. | +| Top-level Work Queue or Matter Case Map/Work/Activity navigation | Rejected and superseded by this ADR. | +| Default-on Legacy routes/runtime or active-module imports from Legacy | Rejected: Legacy is isolated, migration-only, and delete-later. | +| Graph database, browser local state, or hosted service as authoritative state | Rejected absent a new ADR and migration/security design. | +| Tauri or another full desktop-stack rewrite | Rejected: retain Electron, Next.js, Express/TypeScript, SQLCipher, encrypted blobs, Keychain, and durable SQLite jobs. | diff --git a/docs/convergence/current-state-audit.md b/docs/convergence/current-state-audit.md index e9664e4..fd40830 100644 --- a/docs/convergence/current-state-audit.md +++ b/docs/convergence/current-state-audit.md @@ -1,5 +1,12 @@ # Vera Legal Matter Agent Convergence — Current-State Audit +> Historical evidence notice (2026-07-16): this audit remains the detailed +> source record for the `12af6fc5` v14 baseline. The canonical forward product +> decisions and Gate sequence now live in +> `docs/adr/vera-product-convergence.md` and +> `docs/roadmap_legal_workspace.md`. Later commits on the feature branch have +> already isolated Legacy and added Workspace migration v15. + Date: 2026-07-16 Audited baseline: `main` / `origin/main` at `12af6fc53317e96314a980250d3bd12d5bfd3bcb` diff --git a/docs/convergence/data-migration-plan.md b/docs/convergence/data-migration-plan.md index b9667d6..648c306 100644 --- a/docs/convergence/data-migration-plan.md +++ b/docs/convergence/data-migration-plan.md @@ -1,8 +1,14 @@ # Vera Legal Matter Agent Convergence — Data Migration Plan +> Superseded sequencing notice (2026-07-16): retained as historical migration +> design. Migration v15 has since landed on the feature branch, while the +> Proposal-first sequence and later provisional versions are governed by +> `docs/roadmap_legal_workspace.md`. Existing committed migration files remain +> immutable. + Date: 2026-07-16 Baseline Workspace schema: v14 -Status: Phase 0 migration design; no migration is applied by this document +Status: Historical / superseded migration design; no migration is applied by this document ## 1. Migration contract diff --git a/docs/convergence/open-source-reuse-plan.md b/docs/convergence/open-source-reuse-plan.md index d771c36..7deea47 100644 --- a/docs/convergence/open-source-reuse-plan.md +++ b/docs/convergence/open-source-reuse-plan.md @@ -1,5 +1,10 @@ # Vera Legal Matter Agent Convergence — Open-Source Reuse Plan +> Registry notice (2026-07-16): this policy remains applicable, while the +> capability-by-capability canonical decisions required for the revised plan +> are recorded in `docs/reuse_decisions.md`. Source provenance continues to +> live under `docs/provenance/`. + Date: 2026-07-16 Status: Phase 0 approval policy diff --git a/docs/convergence/target-architecture.md b/docs/convergence/target-architecture.md index 4e705cd..3af24ac 100644 --- a/docs/convergence/target-architecture.md +++ b/docs/convergence/target-architecture.md @@ -1,8 +1,14 @@ # Vera Legal Matter Agent Convergence — Target Architecture +> Superseded forward-design notice (2026-07-16): retained as the historical +> architecture explored during the first convergence pass. Its Case Map, +> top-level Work Queue, Artifact-first phase order, and old Matter navigation +> are not the active product plan. The canonical decision is now +> `docs/adr/vera-product-convergence.md`. + Date: 2026-07-16 Baseline: `main` at `12af6fc53317e96314a980250d3bd12d5bfd3bcb` -Status: Phase 0 architecture decision record +Status: Historical / superseded architecture decision record ## 1. Outcome diff --git a/docs/reuse_decisions.md b/docs/reuse_decisions.md new file mode 100644 index 0000000..1da0560 --- /dev/null +++ b/docs/reuse_decisions.md @@ -0,0 +1,507 @@ +# Vera Reuse Decisions + +Date: 2026-07-16 + +Status: Gate 0 decision record + +This document records the reuse boundary for Vera Individual. It complements +the path-level Mike inventory in `docs/mike_port_manifest.md`, the maintained +source inventory in `docs/provenance/open-source-inventory.md`, and the shipped +notices in `THIRD_PARTY_NOTICES.md`. + +Mentioning a candidate here does not authorize source, assets, model weights, +datasets, prompts, product copy, or runtime dependencies to enter Vera. An +external candidate without a reviewed canonical repository, fixed version or +commit, and verified license remains `reference_only` or `reject`. Unknown +license terms are never inferred from a project name, reputation, or prior +version. + +Allowed reuse modes in this record are `direct`, `adapt`, +`extract_algorithm`, `reference_only`, and `reject`. + +## 1. Open Legal Products Mike + +- **Capability:** Product shell, Assistant, Project workspace, Workflows, + Tabular Review, Settings, shared UI primitives, and established interaction + patterns. +- **Existing Vera implementation:** The active Mike-derived Vera client uses + controlled ports documented path by path in `docs/mike_port_manifest.md`. + Vera replaces Mike cloud dependencies with the single local Workspace API, + SQLCipher, encrypted blobs, Keychain credentials, and the existing durable + job runtime. +- **Candidate project:** Open Legal Products Mike, + `https://github.com/Open-Legal-Products/mike`. +- **Exact version/commit:** + `e32daad5a4c64a5561e04c53ee12411e3c5e7238`. Floating upstream branches are + not approved sources. +- **License:** `AGPL-3.0-only`, as recorded in the approved manifest and root + third-party notice. +- **Product fit:** High for the existing desktop information architecture, + interaction model, and reusable legal-workspace UI. +- **Architecture fit:** High only through controlled ports. Mike must not be + nested as a second application, and its Supabase, organization, sharing, + cloud-storage, or server-secret architecture must not be reintroduced. +- **Security impact:** Every adapted path remains subject to Vera loopback + authentication, renderer sandboxing, SQLCipher, encrypted blob, Keychain, + audit, backup/restore, and bounded-request controls. Renderer-only hiding is + not an authorization control. +- **Maintenance impact:** The fixed SHA and source-lock comments make updates + deliberate. Any additional port requires a manifest entry, reviewed diff, + provenance comment, and regression coverage. +- **Decision:** Continue the already-approved controlled reuse. Do not follow + Mike `main` or import unlisted subsystems. +- **Reuse mode:** `direct` for approved isolated components and pure behavior; + `adapt` for the path-specific local-runtime, cloud-removal, Vera-brand, i18n, + security, and accessibility changes recorded in the manifest. +- **Copied/adapted files:** Existing controlled files only, exactly as listed + in `docs/mike_port_manifest.md`. This decision authorizes no additional file + copy. +- **Required notices:** Retain the root `LICENSE`, the Open Legal Products Mike + section in `THIRD_PARTY_NOTICES.md`, `docs/license_attribution.md`, the Mike + manifest, and original copyright/provenance comments in affected files. + +## 2. SQLCipher Node binding + +- **Capability:** Encrypted Workspace SQLite database binding and linked + SQLCipher runtime. +- **Existing Vera implementation:** Workspace encrypted mode uses the existing + `@signalapp/sqlcipher` dependency and verifies the linked cipher/runtime as a + security and release gate. +- **Candidate project:** Signal `@signalapp/sqlcipher`, + `https://github.com/signalapp/node-sqlcipher`. +- **Exact version/commit:** npm package `3.3.9`; the backend lockfile records + the exact package URL and integrity + `sha512-51NAV0CqIEreGx3r0hq85vjHC8NXZhGr9efywaqHRsjpbEdvdYARmFxObmMI55rjyqE5eLQ/QsPJzigBoQ6thw==`. +- **License:** `AGPL-3.0-only`, verified from installed package metadata and + `backend/package-lock.json`. +- **Product fit:** High; it is the established database encryption dependency + for the local desktop product. +- **Architecture fit:** High; it preserves the single Workspace database and + requires no parallel persistence service. +- **Security impact:** Positive only when encryption is mandatory and runtime + attestation, downgrade rejection, file permissions, backup, and restore + checks remain fail-closed. Plain `node:sqlite` must never be described as + SQLCipher-encrypted. +- **Maintenance impact:** Native prebuild compatibility, Electron/Node ABI, + macOS packaging, upstream security fixes, and package integrity require + continued release testing. +- **Decision:** Continue the pinned dependency; upgrades require a separate + dependency, license, native-build, migration, and packaged-runtime review. +- **Reuse mode:** `direct`. +- **Copied/adapted files:** No upstream source is copied into Vera. The exact + dependency is installed through `backend/package.json` and + `backend/package-lock.json`. +- **Required notices:** Retain the SQLCipher Node binding section in + `THIRD_PARTY_NOTICES.md` and distribute the installed package license as + required. + +## 3. Existing Vera Word/Office proof of concept + +- **Capability:** Word task-pane manifest, selected-text capture, and a + review-only handoff into retained Legacy matter records. +- **Existing Vera implementation:** `office-addin/word-manifest.xml` and + `frontend/src/app/office/word/page.tsx` form a Hermes/Aletheia proof of + concept. It is not the target Vera Word Local Bridge and currently depends on + Legacy APIs and product semantics. +- **Candidate project:** Existing Vera repository implementation. The current + provenance record identifies no copied external project source for these + proof-of-concept entry files. +- **Exact version/commit:** Repository commit + `52cdf15cb10dba90896a277c4b7a91d0026ac22f` is the recorded introduction + point for the manifest/page baseline. +- **License:** The repository files are governed by Vera's recorded + `AGPL-3.0-only` baseline. The separately loaded Microsoft Office.js runtime + is not covered by this repository license decision. +- **Product fit:** Partial. Selection capture and review-before-apply behavior + are useful, but Hermes/Aletheia naming, Legacy data ownership, and the current + handoff contract are not active-product foundations. +- **Architecture fit:** Partial. The existing package should be migrated in + place so Vera retains one Add-in, but its data path must move to the single + authenticated `/api/v1` composition root and a bounded local capability + bridge. +- **Security impact:** The target must use short-lived, Matter/document-scoped + capability tokens, an explicit Office-origin allowlist, explicit Matter + selection, bounded selected content, no Keychain access, no provider + credential, and no automatic whole-document upload or mutation. +- **Maintenance impact:** Office host compatibility, manifest validation, + origin/CSP behavior, bridge lifecycle, cross-restart pairing, and packaged + macOS/Word testing create a dedicated integration surface. +- **Decision:** Migrate and replace the existing proof of concept in place + during the Word gate. Do not create or ship a second simultaneous Add-in, and + do not expose the Legacy implementation as the target integration. +- **Reuse mode:** `adapt` for the existing repository package boundary and validated + selection/review behavior; no Legacy repository or route is reused by the + active integration. +- **Copied/adapted files:** Existing + `office-addin/word-manifest.xml` and + `frontend/src/app/office/word/page.tsx`. No new external file copy is + authorized here. +- **Required notices:** Vera's root AGPL notice remains applicable. Before + target release, separately record every Office runtime or official sample + actually used, its exact version/source, terms or license, copied files, and + required Microsoft notices. + +## 4. Microsoft-hosted Office.js runtime + +- **Capability:** Official Office host APIs used by the retained Word proof of + concept. +- **Existing Vera implementation:** The Legacy page loads + `https://appsforoffice.microsoft.com/lib/1/hosted/office.js`; the script is + hosted, not vendored. +- **Candidate project:** Microsoft Office.js hosted runtime and official Office + Add-in documentation/samples. +- **Exact version/commit:** Not pinned. `/lib/1/hosted/office.js` is a moving + hosted endpoint, not an approved immutable version. +- **License:** Not verified for the target Vera distribution in the current + provenance record. Microsoft platform terms, official sample licenses, and + distribution requirements require review. +- **Product fit:** Potentially high because Office.js is the official Word + Add-in API, but only the minimum capabilities required by Vera should be + used. +- **Architecture fit:** Reference fit only until the target manifest, + origin/CSP model, local bridge, and runtime distribution approach are + reviewed. +- **Security impact:** A moving hosted script expands supply-chain and network + trust. The Add-in must not receive provider credentials or unrestricted local + access, and host-origin/capability checks must be authoritative in the local + bridge. +- **Maintenance impact:** Microsoft host/API compatibility and terms can change + independently of Vera; exact supported requirement sets and update policy + must be owned by the Word integration. +- **Decision:** Keep the current reference confined to the retained Legacy POC. + Do not approve it as the target runtime dependency until pinning/terms and + security review are complete. +- **Reuse mode:** `reference_only`. +- **Copied/adapted files:** None. The current Legacy page contains only a hosted + script reference; no Office.js source is copied into Vera. +- **Required notices:** None are newly asserted by this record. The Word gate + must determine and add the exact notices and terms required by the runtime or + official samples actually selected. + +## 5. June + +- **Capability:** Saved-audio-first patterns, separation of local data and + hybrid inference, crash recovery, agent sandboxing, and tool-broker concepts. +- **Existing Vera implementation:** Vera already owns local encrypted storage, + durable jobs, model gateway, credential isolation, and fail-closed lifecycle + controls. Conversation capture has not been approved as a new active module. +- **Candidate project:** June; no canonical repository or edition is approved + in the current provenance inventory. +- **Exact version/commit:** Not pinned. +- **License:** Not verified. No license is inferred. +- **Product fit:** Useful for architecture study, especially saved-audio-first + and recovery behavior; general computer/media-agent features are out of Vera + scope. +- **Architecture fit:** Reference fit only. Vera must not be rewritten to + Tauri, embed Hermes, or import a second agent runtime, database, or frontend. +- **Security impact:** Importing unreviewed sandbox, capture, credential, or + tool code could expand filesystem, process, network, and secret access. +- **Maintenance impact:** Unknown until the exact project, edition, + dependencies, release model, and native components are identified. +- **Decision:** Study public architecture concepts only. No code, assets, + prompts, models, or dependencies may be copied. +- **Reuse mode:** `reference_only`. +- **Copied/adapted files:** None. +- **Required notices:** None while no source is copied. Any future proposal + requires a fixed source, verified license, file-level provenance, dependency + review, and corresponding notices before implementation. + +## 6. Meetily + +- **Capability:** Future device/system-audio capture, VAD, noise reduction, + local transcription integration, and crash-recoverable recording patterns. +- **Existing Vera implementation:** The active product has no approved + Conversation capture runtime. A Legacy Python voice sidecar remains isolated + and is not a target implementation. +- **Candidate project:** Meetily; the current inventory records it only as a + research candidate. +- **Exact version/commit:** Not pinned. +- **License:** Not verified. No license is inferred. +- **Product fit:** Potential future fit for bounded audio primitives after + Conversation requirements are validated; its meeting UI, database, + summarizer, updater, and application shell do not fit Vera. +- **Architecture fit:** Any approved primitive would have to be isolated in a + minimal capture sidecar with no Matter, credential, Review, or document + ownership. No Tauri shell or second database is permitted. +- **Security impact:** Audio-device and system-audio access, raw-audio + retention, native permissions, model loading, and updater behavior require + dedicated review. +- **Maintenance impact:** Native macOS APIs, Rust crates, model adapters, + packaging, permissions, and upstream compatibility would add material cost. +- **Decision:** Architecture reference only until exact source, file scope, + license, transitive dependencies, and security tests are approved. +- **Reuse mode:** `reference_only`. +- **Copied/adapted files:** None. +- **Required notices:** None while no source is copied. A future approved import + must add the exact upstream copyright/license, selected file inventory, + transitive notices, and separately reviewed model notices. + +## 7. Vexa + +- **Capability:** Online meeting-bot and pre-/post-meeting knowledge workflows. +- **Existing Vera implementation:** Vera has no active meeting-bot cluster and + does not need one for the Individual desktop product. +- **Candidate project:** Vexa; the current inventory records it only as a + research candidate. +- **Exact version/commit:** Not pinned. +- **License:** Not verified. No license is inferred. +- **Product fit:** Low for the desktop MVP; selected public workflow concepts + may inform later Conversation planning. +- **Architecture fit:** Poor for active reuse because a bot cluster, Docker or + Kubernetes runtime would create a parallel deployment and operations model. +- **Security impact:** Meeting credentials, external network access, + participant data, recording consent, and multi-service storage materially + exceed the current product boundary. +- **Maintenance impact:** High and unjustified for Vera Individual. +- **Decision:** Use only as a public product/architecture reference. Do not + import its bot runtime, services, UI, storage, or deployment stack. +- **Reuse mode:** `reference_only`. +- **Copied/adapted files:** None. +- **Required notices:** None while no source is copied. + +## 8. LangExtract + +- **Capability:** Schema-constrained extraction, exact-source grounding, and + refusal when source location cannot be established. +- **Existing Vera implementation:** Vera already has a model gateway, immutable + Source Snapshot, Citation Anchor, bounded document retrieval, and strict + TypeScript contracts that should own the target extraction boundary. +- **Candidate project:** LangExtract; no reviewed source pin is present in the + current inventory. +- **Exact version/commit:** Not pinned. +- **License:** Not verified. No license is inferred. +- **Product fit:** High as a conceptual reference for grounded extraction. +- **Architecture fit:** The preferred implementation is a thin Vera-native + contract over the existing model/source stack. A permanent Python sidecar or + parallel provenance model does not fit without a demonstrated gap. +- **Security impact:** Any runtime reuse would need bounded inputs/outputs, + prompt and source-content controls, provider policy enforcement, safe errors, + and exact-anchor verification. +- **Maintenance impact:** External runtime and schema drift would be avoidable + cost unless a measured capability gap justifies them. +- **Decision:** Reproduce the required behavior independently in Vera using + public concepts; do not copy code before a separate pin/license review. +- **Reuse mode:** `reference_only`. +- **Copied/adapted files:** None. +- **Required notices:** None while no source is copied. Public conceptual + reference must not be represented as imported implementation. + +## 9. Graphiti + +- **Capability:** Temporal facts, validity intervals, supersession, relations, + and provenance concepts. +- **Existing Vera implementation:** Vera's target uses SQLCipher tables, + immutable revisions, typed relations, and source links; no graph server is + required. +- **Candidate project:** Graphiti; no reviewed source pin is present in the + current inventory. +- **Exact version/commit:** Not pinned. +- **License:** Not verified. No license is inferred. +- **Product fit:** Useful as a conceptual reference for temporal and + supersession semantics. +- **Architecture fit:** Source/runtime reuse does not fit the current product. + Neo4j, FalkorDB, or another graph database would violate the single-database + boundary. +- **Security impact:** A graph service would create new storage, network, + backup, encryption, access-control, and deletion surfaces. +- **Maintenance impact:** High relative to a bounded SQLCipher revision model. +- **Decision:** Reference concepts only; independently implement required + temporal semantics in the existing Workspace database. +- **Reuse mode:** `reference_only`. +- **Copied/adapted files:** None. +- **Required notices:** None while no source is copied. + +## 10. FunASR + +- **Capability:** Future Chinese ASR, VAD, punctuation, hotwords, and related + speech-model adapters. +- **Existing Vera implementation:** No FunASR toolkit, model, weight, or dataset + is approved or bundled. The retained Legacy voice adapter is not approval for + a new Conversation runtime. +- **Candidate project:** FunASR toolkit and separately selected models/weights. +- **Exact version/commit:** Not pinned for code; no model or weight version is + selected. +- **License:** Not verified for either toolkit code or any model, weight, + dataset, training data, or redistribution terms. No license is inferred. +- **Product fit:** Potentially high for optional Chinese transcription after + Conversation requirements and quality targets are established. +- **Architecture fit:** Only a bounded optional adapter could fit. It must not + own Matter, Source, Proposal, Review, credentials, or a second job/database + runtime. +- **Security impact:** Model loading, native/Python execution, audio retention, + network downloads, cache paths, and model provenance require separate + controls. +- **Maintenance impact:** High because toolkit compatibility and every selected + model artifact need independent versioning, quality, packaging, and license + review. +- **Decision:** No implementation reuse or model distribution is approved. + Evaluate only when the optional Conversation gate begins. +- **Reuse mode:** `reference_only`. +- **Copied/adapted files:** None. +- **Required notices:** None now. Future approval requires separate notices and + provenance for toolkit code and for every selected model/weight/dataset. + +## 11. WhisperX + +- **Capability:** Future speech alignment and word-level timestamps. +- **Existing Vera implementation:** Vera has no approved WhisperX code, + dependency, model, or alignment weight in the active product. +- **Candidate project:** WhisperX and separately selected Whisper/alignment + models. +- **Exact version/commit:** Not pinned for code; no model or weight version is + selected. +- **License:** Not verified for the complete code/dependency/model chain. No + license is inferred. +- **Product fit:** Potential future fit for precise transcript anchors, subject + to measured Chinese-language quality and packaging feasibility. +- **Architecture fit:** Only an optional transcription/alignment adapter may + fit; it cannot introduce a second Matter/source/provenance model. +- **Security impact:** Python/native execution, model acquisition, cache + locations, audio handling, and potential network access require review. +- **Maintenance impact:** High due to model/runtime compatibility, native + dependencies, packaging size, and hardware variation. +- **Decision:** Research reference only until code and every selected model are + independently pinned and licensed. +- **Reuse mode:** `reference_only`. +- **Copied/adapted files:** None. +- **Required notices:** None now. Future approval requires code, dependency, + model, and dataset provenance/notices as applicable. + +## 12. pyannote + +- **Capability:** Future speaker diarization and speaker-segment attribution. +- **Existing Vera implementation:** No pyannote code or model is approved or + bundled. Speaker correction remains a future Conversation capability. +- **Candidate project:** pyannote toolkit and separately gated diarization + models. +- **Exact version/commit:** Not pinned for code; no model revision is selected. +- **License:** Not verified. Toolkit licensing alone would not establish model + access, use, redistribution, or training-data rights; no license is inferred. +- **Product fit:** Potential fit for optional diarization after user need and + Chinese/multilingual quality are validated. +- **Architecture fit:** Only a bounded adapter may fit. Human speaker correction + must remain authoritative and machine attribution must not overwrite the + reviewed transcript layer. +- **Security impact:** Gated model access, account tokens, model downloads, + local caches, biometric/privacy implications, and raw-audio handling require + dedicated review. +- **Maintenance impact:** High due to model gating, version compatibility, + quality evaluation, hardware requirements, and redistribution constraints. +- **Decision:** No code or model reuse is approved; retain as a future research + reference only. +- **Reuse mode:** `reference_only`. +- **Copied/adapted files:** None. +- **Required notices:** None now. Future approval requires separate toolkit, + model, weight, and dataset provenance and notices. + +## 13. Screenpipe + +- **Capability:** Timeline and local-capture interaction concepts. +- **Existing Vera implementation:** Vera does not need continuous screen + capture for its legal workspace and has no approved Screenpipe source or + runtime. +- **Candidate project:** Screenpipe or any similarly named/packaged edition. +- **Exact version/commit:** No approved source or version is pinned. +- **License:** Not approved. The current inventory explicitly warns that + source-available or commercial terms must not be treated as ordinary + open-source permission. +- **Product fit:** Low. Continuous screen capture is outside the current Matter, + document, source, Review, and optional Conversation scope. +- **Architecture fit:** Poor; it would add broad capture, storage, indexing, + retention, and potentially background-agent surfaces. +- **Security impact:** Unbounded screen content can expose credentials, + privileged communications, unrelated matters, personal data, paths, and + third-party applications. +- **Maintenance impact:** High and not justified by the approved roadmap. +- **Decision:** Use only public high-level timeline and local-capture + interaction concepts as a reference. Reject source, runtime, model, and asset + reuse absent separate written authorization and a future product decision + that changes scope. +- **Reuse mode:** `reference_only` for public concepts; source, runtime, model, + and asset reuse remains rejected. +- **Copied/adapted files:** None. +- **Required notices:** None because no source or assets are incorporated. + +## 14. Harvey + +- **Capability:** Publicly described enterprise legal AI capability families, + including Assistant, document work, workflow agents, knowledge, + integrations, and governance. +- **Existing Vera implementation:** Vera independently implements its local + Workspace, documents, Assistant, workflows, sources, Studio, security, and + planned Review/Knowledge boundaries. +- **Candidate project:** Harvey, a proprietary commercial product, as a public + product-capability reference only. +- **Exact version/commit:** Not applicable; no source repository, version, or + implementation is licensed to Vera. +- **License:** Proprietary; Vera has no reuse license recorded for source, + assets, prompts, data, or non-public implementation. +- **Product fit:** Useful only for high-level capability-gap and user-value + comparison. +- **Architecture fit:** Public concepts can inform independent planning. Its + private architecture, implementation, APIs, prompts, and workflows are not + Vera dependencies. +- **Security impact:** Copying or reverse engineering non-public behavior would + create legal, provenance, and potentially security risks. Public references + must not be used to justify fake provider or enterprise capabilities. +- **Maintenance impact:** Low when limited to occasional public capability + review; unacceptable if treated as an implementation dependency. +- **Decision:** Use only public product information for independent capability + comparison. Do not copy or imitate protected implementation, branding, + assets, text, prompts, data, private APIs, or non-public workflows. +- **Reuse mode:** `reference_only`. +- **Copied/adapted files:** None. +- **Required notices:** None because nothing is incorporated. Do not use Harvey + trademarks or branding as Vera product assets. + +## 15. Legora + +- **Capability:** Publicly described Matter workspace, multi-file context, + Assistant, Tabular Review, Workflows, Word work, drafting, legal research, + playbooks, and human-review concepts. +- **Existing Vera implementation:** Mike-derived Vera already owns the active + product shell and independently implements Projects/Documents, Assistant, + Tabular Review, Workflows, Source Snapshot, Citation Anchor, and Document + Studio. +- **Candidate project:** Legora, a proprietary commercial product, as a public + product-capability reference only. +- **Exact version/commit:** Not applicable; no source repository, version, or + implementation is licensed to Vera. +- **License:** Proprietary; Vera has no reuse license recorded for source, + assets, prompts, data, or non-public implementation. +- **Product fit:** Useful only for capability-gap, information-architecture, + and user-value comparison without pixel-level imitation. +- **Architecture fit:** Public concepts may inform an independently designed + flow. Legora code, design assets, private APIs, prompts, data models, and + non-public workflows cannot become dependencies. +- **Security impact:** Copying, scraping, or reverse engineering protected + material creates legal and provenance risk and can bypass Vera's controlled + security boundaries. +- **Maintenance impact:** Low when limited to public product research; + unacceptable if Vera tracks proprietary UI or behavior as an implementation + specification. +- **Decision:** Reference public capabilities only. Do not copy branding, + trademarks, screenshots, UI assets, product copy, private prompts, source, + data, private APIs, or non-public implementation. +- **Reuse mode:** `reference_only`. +- **Copied/adapted files:** None. +- **Required notices:** None because nothing is incorporated. Do not use Legora + trademarks or branding as Vera product assets. + +## Approval rule for future changes + +Before any `reference_only` or `reject` entry can become `direct`, `adapt`, or +`extract_algorithm`, the implementation commit must update this record and the +maintained provenance inventory with: + +1. canonical repository and fixed commit/tag/package version; +2. retrieval date and exact upstream paths; +3. verified license identifier, license-file hash, copyright headers, and + model/dataset terms where applicable; +4. selected Vera destination files and excluded upstream subsystems; +5. dependency, native-build, packaging, security, and maintenance review; +6. required root/package notices and retained source headers; and +7. focused and packaged validation evidence appropriate to the capability. + +The provenance and notice update must land in the same commit as any approved +source or dependency import, never afterward. diff --git a/docs/roadmap_legal_workspace.md b/docs/roadmap_legal_workspace.md new file mode 100644 index 0000000..cf565b1 --- /dev/null +++ b/docs/roadmap_legal_workspace.md @@ -0,0 +1,373 @@ +# Vera Legal Workspace Roadmap + +Date: 2026-07-16 + +Status: canonical forward plan + +Code baseline: `origin/main` at `12af6fc5` (Workspace schema v14) + +Feature-branch baseline: `feat/legal-matter-agent-convergence` at `408333d7` +(Workspace schema v15) + +## 1. Delivery objective + +Vera Individual evolves in bounded, reversible vertical slices from the +Mike-derived local Workspace into a Matter-centric legal workspace. The active +product continues to use one Electron lifecycle, one Next.js renderer, one +loopback Express backend, one SQLCipher Workspace database, one encrypted blob +store, one durable Job Runtime, and one model gateway. + +The sequence deliberately puts a truthful, source-backed human review boundary +before broader automation. It does not make Case Map, autonomous litigation, +multi-user SaaS, real-time voice, or a second document/model runtime a +prerequisite for useful legal work. + +The canonical architecture decision is +[`docs/adr/vera-product-convergence.md`](adr/vera-product-convergence.md). The +gap and reuse evidence live in +[`docs/vera_legora_harvey_gap_analysis.md`](vera_legora_harvey_gap_analysis.md) +and [`docs/reuse_decisions.md`](reuse_decisions.md). + +## 2. Current branch disposition + +Three commits predate this revised roadmap: + +| Commit | Disposition | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `eb9e75e0` | Retain its source-backed audit as historical evidence. New canonical Gate 0 documents supersede its forward IA and phase ordering. | +| `edf26827` | Retain unchanged. It provides the required default-off Legacy route/runtime flags and lazy loading. | +| `408333d7` | Retain as an additive, already-committed v15 foundation. Its narrow `matter_type` is transitional and must not become the new public product taxonomy. | + +Migration v15 is treated as immutable. Gate 1 therefore adds a v16 +classification migration rather than changing the v15 checksum. Uncommitted +Matter API/UI work created against the earlier plan is not product evidence and +must be adapted before it can land. + +## 3. Gate sequence + +### Gate 0 — Audit and convergence baseline + +Deliverables: + +- the four canonical documents required by this roadmap; +- an inventory of active routes, tables, runtimes, jobs, source/citation + ownership, frontend navigation, packaging gates, and Legacy side effects; +- a capability-family comparison using public product information only; +- fixed reuse decisions and explicit no-copy boundaries; +- this executable roadmap and the convergence ADR; +- supersession notices on older forward-looking convergence documents. + +Exit criteria: + +- every current-state claim is backed by code, test, migration, or fixed-source + evidence; +- `origin/main` v14 and feature-branch v15 are not conflated; +- no uncommitted implementation is described as delivered; +- no new product feature is added in the Gate 0 commit. + +### Gate 1 — Product convergence and Matter Profile + +Gate 1 is delivered as separate schema, API, UI, and release-evidence commits. + +#### 1A. Legacy isolation + +Already implemented by `edf26827`: + +- `VERA_ENABLE_LEGACY_ROUTES` and `VERA_ENABLE_LEGACY_RUNTIME` require the exact + string `true`; +- official desktop and Compose defaults keep both off; +- Legacy modules are loaded only inside the enabled boundary; +- the Workspace product remains functional without Legacy routes, seed, or + runtime. + +#### 1B. Matter classification migration + +Add v16 without editing v15: + +- `workspace_type`: `general_legal`, `transaction`, `dispute`, + `investigation`, `compliance`, or `research`; +- bounded optional `jurisdiction`; +- existing v15 rows remain readable with an explicit + `classification_required` capability until the user selects a value; +- new Matter creation requires `workspace_type`; +- no automatic mapping guesses from the older v15 `matter_type` values. + +Because v15 made `matter_type` required, the v16 compatibility writer stores +the fixed non-semantic sentinel `general` in that legacy column for every new +row. It never derives a v15 value from `workspace_type`, never returns the +legacy field as public classification, and never backfills an existing v15 row +by guess. `workspace_type` stays nullable only so a pre-v16 row can surface as +`classification_required`; the v16 service requires it for every new Matter. + +Canonical ownership avoids duplicate fields: + +- Project owns name, description, lifecycle status, `cm_number` (presented as + Matter number), and `practice` (presented as practice area); +- Matter Profile owns workspace classification, client name, jurisdiction, + represented role, and objective; +- v15 litigation-oriented fields remain bounded transitional metadata, do not + drive navigation, and are not silently promoted to sourced facts. + +#### 1C. Matter module and API + +Add the Matter Profile module beneath the existing `/api/v1` authentication and +audit-mutation boundary: + +```text +GET/POST /api/v1/matters +GET /api/v1/matters/:projectId +GET/POST/PATCH /api/v1/projects/:projectId/matter-profile +``` + +Rules: + +- a new Matter atomically creates one Project and one Matter Profile in the + existing Workspace database; +- a generic Project is never silently converted; +- list/read projections expose nullable `workspace_type`, exact + `profile_state: absent | classification_required | ready`, and truthful + feature capabilities; +- the compatibility projection may include generic Projects, but the UI must + put them in a separate “add Matter Profile” section; +- Project APIs and `/projects/:id/**` deep links remain valid; +- `tabular_review_count` keeps its real name; it is not presented as unified + Review count; +- Profile persistence, Project creation, and Overview aggregation remain + separate owner ports. One application service/transaction coordinator + composes Project and Profile writes on the same WorkspaceDatabase + transaction; owner ports do not begin or commit nested transactions; +- health reports Profile schema readiness separately from the future Inference + Policy. + +#### 1D. Matter UI + +Use the current design system and complete Chinese/English i18n: + +- top navigation: Assistant, Matters, Workflows, Review, Settings; +- Review is visibly unavailable until Gate 2 has a real backend; +- `/matters` supports list, explicit conversion, and atomic creation; +- `/matters/:id` provides a real Overview and edit capability; +- Matter navigation is Overview, Documents, Assistant, Review, Workflows, + Drafts; +- Documents, Assistant, and Workflows reuse existing Project-scoped routes; +- Review remains disabled until Gate 2; +- Drafts links only to real Studio-backed content; if a complete Draft list + cannot be derived, it is marked unavailable rather than populated with fake + data; +- the exact `/projects` list route can redirect to `/matters`, while every + dynamic Project, Tabular, and Studio compatibility route remains intact; +- the application landing route stays `/assistant` unless a later ADR changes + it. + +#### 1E. Interim inference safety + +The v15 Matter Policy tables are not evidence of an implemented Inference +Policy. Before Gate 1 is declared complete, model calls for a Project with a +Matter Profile must pass a backend-owned interim gate. Missing policy or an +empty allowed execution-location set denies generation. The renderer cannot +bypass this via a Project deep link. Generic Projects and global Assistant keep +their existing P0 model-readiness behavior during this compatibility window; +they cannot use `matter_policies`, whose foreign key deliberately requires a +Matter Profile. + +Gate 1 does not add permissive policy defaults. Gate 3 supplies verified model +privacy metadata and the user-facing controls needed to enable Matter +inference safely, plus an explicit Workspace/global inference-policy port and +persistence fallback for generic Projects and global Assistant. That fallback +must not create a Matter Profile or silently convert a Project. + +### Gate 2 — Proposal contract and Unified Review Center + +Use a new additive migration after v16 for: + +```text +workspace_proposals +proposal_source_anchors +proposal_resolution_events +``` + +The first complete slice is a Document Studio suggestion projected into the +Review Center. Acceptance delegates to the existing authoritative Studio +service, re-reads the current suggestion and base version, validates source, +retention, stale state, and audit health, then records the resolution in the +same database transaction as the formal version change. + +Adapters then expand in this order: + +1. OCR warning: acknowledge, defer, or reopen through typed resolution events; + it is not a legal fact and those actions are not Proposal lifecycle states. +2. Workflow output: create a reviewable Draft, not formal Matter state. +3. Tabular result: create a reviewable Draft/export selection, preserving the + authoritative Tabular owner. +4. Assistant-to-Draft action: explicit proposal, never direct overwrite. + +Proposal lifecycle is exactly `open`, `accepted`, `modified`, `rejected`, or +`superseded`. Type-specific actions are recorded in resolution events and do +not add shadow lifecycle states. + +Fact, issue, task, and other structured acceptance are not claimed until their +formal owner and atomic promotion contract exist. Review items reference the +authoritative payload; adapters do not clone entire Studio, OCR, Workflow, or +Tabular records. + +Matter Overview gains a real open-Proposal count only after the Review query is +live. Review becomes an enabled top-level and Matter navigation destination at +that point. + +#### Gate 2B — Work Queue projection + +The new plan names Work Queue as necessary but does not assign it a numbered +Gate. It is therefore an explicit Gate 2 follow-on, not a new top-level product +area. Review can expose Queue as a secondary mode. + +- aggregate open Proposals, failed/retryable Jobs, OCR warnings, Studio + suggestions, and waiting Workflow work by stable references; +- add a formal table only for user-created tasks; +- do not copy source payloads or create a second job state machine; +- define deduplication, completion history, and Project ownership. + +### Gate 3 — Inference Policy and Knowledge + +Split this Gate into independently migrated and tested slices. + +#### 3A. Inference Broker policy + +- extend Model Profile with declared execution location, retention, training + use, sensitive-data permission, and attestation; +- expose Matter Policy API/UI with deny-all defaults; +- enforce Source retention/model-use policy at the last outbound boundary; +- return only `allow`, `allow_after_redaction`, `require_approval`, or `deny`; +- record bounded egress audit metadata answering what, where, model, policy, + redaction, approval, retention, and training-use questions; +- never infer “local” solely from a URL. + +#### 3B. Knowledge Collections + +- create Personal and Matter logical collections; +- collection items reference existing Document Versions, Source Snapshots, + Workflows, or Templates and never duplicate blobs/content; +- use existing FTS5 and source anchors before considering another index; +- require explicit user selection for Personal-to-Matter use; +- reject persistent cross-Project references by default; any explicitly + authorized Personal-to-Matter use is revalidated on every read/model use, + follows source retention and deletion, becomes unavailable after tombstone, + and has focused isolation/backup tests; +- define `FirmKnowledgePort`, but do not emulate multi-user Firm Knowledge in + the local database. + +### Gate 4 — Authorized China legal-source loop + +- extract the legal-source broker boundary from Legacy ownership without + importing Legacy runtime or tables into the active product; +- choose one licensed Provider and document its search/full-text/citation, + jurisdiction, pagination, retention, export, and model-use capabilities; +- validate real credentials and a real authorized search/full-text/snapshot/ + anchor/user-selection/Draft flow; +- expose `unavailable`, `configured_unverified`, or + `activation_gate_closed` accurately when the external boundary is absent; +- never use browser cookies, scraping, private endpoints, or fixtures as live + acceptance evidence. + +Provider credentials, contract rights, or network access may be a real external +blocker. Internal ports and truthful unavailable states can land, but Gate 4 is +not complete until one authorized vertical flow passes. + +### Gate 5 — Word integration + +Migrate the existing `office-addin` proof of concept into the only Office +Add-in package; do not copy the full Vera frontend or add a parallel Add-in. + +- official Office.js origin and license/terms review; +- short-lived, origin-bound, Matter- and document-session-scoped capability + tokens; +- no Keychain or Provider credential access from the Add-in; +- explicit Matter selection and bounded source search; +- insertion of Assistant output and citations; +- selected-text rewrite as a Proposal; +- source check and explicit Draft/version creation; +- no automatic whole-document upload or unreviewed overwrite. + +### Gate 6 — Team boundary preparation + +Introduce narrow ports and local single-user adapters only: + +```text +IdentityPort +MatterAclPort +FirmKnowledgePort +FirmPolicyPort +FirmAuditPort +``` + +Document a Firm Hub topology separately. Do not create tenants, simulated team +members, shared spaces, or an in-process multi-user SaaS in Electron. + +### Gate 7 — Optional Conversation Source + +Only after the core review, inference, source, and drafting loops are stable: + +- import approved audio formats into the existing encrypted blob store; +- use an optional local transcription adapter; +- persist machine and reviewed transcript layers separately; +- create transcript snapshots and timestamp anchors; +- make speaker correction explicit; +- send extracted candidates to Review; +- update a Matter only after an accepted Proposal. + +Real-time capture remains a later sidecar and must pass separate provenance, +license, native packaging, saved-audio recovery, and model-weight reviews. + +## 4. Provisional migration order + +Only v15 is committed. Later numbers are planning reservations and can be split +before their migration lands: + +| Version | Domain | +| ------- | ---------------------------------------------------------------------------- | +| v15 | Existing Matter Profile and dormant fail-closed Matter Policy foundation | +| v16 | Broad workspace classification and jurisdiction | +| v17 | Proposal and resolution contract | +| v18 | User-created tasks for the Work Queue | +| v19 | Model privacy metadata and source/inference policy evolution | +| v20 | Knowledge Collections and reference-only items | +| v21+ | Legal-source, Word, or Conversation state only when its contract is approved | + +Released migration files and checksums are immutable. Every new migration is +additive, transactional, contiguous, bounded, and preserves Project ownership, +existing Workspace data, and Legacy tables. A documented additive +rollback/recovery path means a schema-aware compatibility binary may leave new +tables unused, while an older binary fails closed and restores the verified +pre-migration encrypted backup; it never means a destructive down migration. + +## 5. Validation matrix + +Every Gate executes and reports the complete baseline required by the product +plan: backend build, frontend lint/build, P0/P1 regression, focused tests, +migration fresh/current/Legacy upgrade, restart persistence, Job lifecycle, +encrypted Blob, plaintext-secret checks, safe errors, cross-Matter source and +citation isolation, stale/tombstone enforcement, truthful provider state, +Legacy default-off, and a current packaged macOS cross-restart E2E. A check +that the Gate does not exercise is still run against the retained +implementation where an existing suite exists; otherwise it is recorded as +not applicable and does not count as passed evidence for a new capability. + +| Change class | Mandatory evidence | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Documentation | Links, filenames, baseline SHAs, source/provenance consistency, no unsupported completion claims, plus the complete retained-product baseline above | +| Every code Gate | Backend build; frontend lint/build; existing P0/P1 suites; focused tests; Legacy default-off; authentication/audit order; safe-error and secret/path checks | +| Schema | Fresh DB; v14/current-prefix upgrade; Legacy-table sentinel; SQLCipher; checksum; idempotence; injected rollback; ownership and backup/restore preservation | +| Job | Durable status, cancellation, retry, interrupted restart recovery, bounded input/output, safe errors | +| Proposal | Cross-Matter isolation; stale target; tombstoned/expired source; authoritative reread; atomic resolution/formal change; restart persistence | +| Provider | Truthful unavailable/configured states plus at least one authorized non-fixture vertical test before completion | +| Every Gate packaged baseline | Build or verify a `Vera.app` from the current commit and run the macOS cross-restart E2E; Word/Conversation add capability/origin/encrypted-blob cases | + +Every Gate keeps older routes and data usable, documents its additive +rollback/recovery path, and ends with an accurate blocker list. README product +claims change only after the corresponding real vertical acceptance passes. + +## 6. Immediate next slice + +After Gate 0 is committed, Gate 1 resumes with v16 and the corrected Matter +public contract. The already-created uncommitted API and UI files are reviewed +line by line and adapted; they are not accepted merely because focused tests +for the earlier contract once passed. diff --git a/docs/vera_legora_harvey_gap_analysis.md b/docs/vera_legora_harvey_gap_analysis.md new file mode 100644 index 0000000..1aa1fb1 --- /dev/null +++ b/docs/vera_legora_harvey_gap_analysis.md @@ -0,0 +1,247 @@ +# Vera / Legora / Harvey 能力差距分析 + +日期:2026-07-16 + +审计对象:`shawliu998/Vera` 当前合并基线与功能分支提交状态 + +目标:为 Vera 从 Mike-derived 单用户桌面客户端收敛为面向中国法律团队的 Matter-centric AI Workspace 提供事实基线;本文不是竞品复刻说明,也不把未提交代码当作已完成能力。 + +## 1. 审计边界与证据口径 + +本文刻意区分三个状态: + +| 状态 | 提交 | Workspace 最高 migration | 本文如何使用 | +| ---------------------- | ------------------------------------------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------ | +| `origin/main` 合并基线 | `12af6fc53317e96314a980250d3bd12d5bfd3bcb` | v14 | 表示当前主分支事实;Legacy 仍在正常后端启动中加载和挂载。 | +| 当前功能分支 `HEAD` | `408333d7` | v15 | 表示已经提交、可以审计的 Phase 1 与 Phase 2A 结果;Legacy 默认隔离,Matter 仅有持久化基础。 | +| 当前工作树未提交内容 | 不属于任何提交 | 不适用 | **不计入当前能力、完成状态或验收结论**。其中包括正在试验的 Matter repository/service/API、runtime 接线和前端文件。 | + +因此,下文的“Vera 当前能力”默认指 `HEAD 408333d7` 已提交事实;涉及主分支差异时会明确写出 `origin/main`。功能分支尚未合并,不能把 v15 或 Legacy 默认隔离描述为主分支/正式发布已经具备。 + +证据优先级为:可执行代码与迁移 > 测试与实现记录 > README/产品文字。竞品信息只引用 Legora、Harvey 的官方公开页面,代表其公开描述的能力族,不证明其内部实现、质量、安全边界、可用地区或与 Vera 的等价性。本文不使用非公开代码、提示词、数据或受版权保护的界面细节。 + +主要仓库证据: + +- [当前状态审计](convergence/current-state-audit.md) +- [Phase 1 Legacy 隔离记录](convergence/phase-1-legacy-isolation.md) +- [Phase 2A Matter Foundation migration 记录](convergence/phase-2a-matter-foundation-migration.md) +- [目标架构](convergence/target-architecture.md) +- [数据迁移计划](convergence/data-migration-plan.md) +- [Mike port manifest](mike_port_manifest.md) +- [P0 Mike 桌面迁移](p0_mike_desktop_migration.md) +- [P1 OCR 与 Document Studio](p1_ocr_legal_document_studio.md) +- [桌面运行说明](desktop_app.md) + +## 2. 结论先行 + +Vera 已经有一个真实、可复用的本地法律工作底座,而不是静态 Demo:加密 Workspace、Project/Document、持久 Assistant、Workflow、Tabular Review、OCR、不可变 Source Snapshot/Citation Anchor、Document Studio、DOCX、模型配置、Backup/Restore 与 fail-closed 检查都有已提交实现和相应门禁。 + +与目标产品的主要差距不是“再加一个聊天功能”,而是尚未把现有工具收敛到统一 Matter 语义和统一人工复核链路: + +1. Project 仍是通用技术容器;`HEAD` 没有已提交的 Matter Profile API、Matter Overview 或面向用户的 Matter 导航。 +2. v15 只增加 Matter Profile/Policy 表,且 profile 分类与最新目标合同存在语义漂移;策略表尚不是运行时 Inference Broker 的执行证据。 +3. Studio suggestion、OCR warning、Workflow output、Tabular result 各有自己的结果形态,还没有 Proposal Contract 和统一 Review Center。 +4. Source Snapshot 目前只支持 `project_document` 与 `legal_authority`,尚未覆盖邮件、会话、手工笔记和集成来源。 +5. 中国法律数据源已有适配/配置/留存/激活边界,但没有可以据实宣称完成的、真实授权 Provider 纵向闭环。 +6. 当前是单用户本地桌面产品;团队 ACL、Firm Knowledge、Firm Policy、SSO 和多用户审计只能先定义 Port,不能在 Electron 后端中伪造 SaaS。 + +功能分支 Phase 1 已经修复最危险的产品分叉:默认客户端不再加载 Legacy 路由或后台 runtime。不过 Legacy 源码、表、前端深链和资源仍被保留用于迁移、算法复用和回归;这叫“隔离”,不是“迁移完成”或“删除完成”。 + +## 3. 当前已验证能力 + +| 能力族 | `HEAD 408333d7` 已提交事实 | 当前边界 | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 桌面与本地服务 | macOS Electron 管理 Next.js 前端和 Express 后端生命周期;服务绑定 loopback;每次启动使用随机 bearer;renderer 开启 sandbox/context isolation 并限制导航、权限和新窗口。 | 当前产品是单用户 macOS 桌面版,不是多租户服务。 | +| 数据安全 | 单一 Workspace 数据库、SQLCipher、加密 Blob、macOS Keychain、迁移 checksum、SQLite/SQLCipher integrity、Backup/Restore 和 fail-closed restore 检查。 | 新模块必须沿用同一数据库、Blob 和迁移 runner;不得建立第二套存储。 | +| Projects 与 Documents | Project CRUD/归档,文件夹、上传、版本、解析/OCR、重试、删除、下载等真实 API。`projects` 已有 `cm_number` 与 `practice`。 | Project 尚无已提交的法律 Matter API/UI;`cm_number` 和 `practice` 应分别作为 Matter number 与 practice area 的现有规范字段,避免 profile 再造同义字段。 | +| Assistant | 全局和 Project chat、流式生成、持久事件、恢复、取消、重试和 regenerate;附件与 Project 绑定受检查。 | 输出仍直接属于 chat/message 语义,未统一投影为待审 Proposal。 | +| Workflows | Workflow CRUD、definition、持久 run、进度、取消、重试和恢复,共用 Workspace job pump。 | 尚无 Matter 级 Work composition、统一输出 adapter、来源/策略复核和经批准的团队模板层。 | +| Tabular Review | Project/全局 Tabular Review、生成/重生成/取消、导出、持久单元格 job 和 Mike 兼容语义。 | “Tabular review 结果”不等于统一 Review Center;两者必须通过 adapter 连接而不是混名。 | +| 来源与引用 | Project Document/Legal Authority 的 Source Snapshot、内容、Citation Anchor、retention lifecycle 与 tombstone 语义。 | 当前 source kind 是封闭的两类;还没有 Email、Conversation Transcript、Manual Note、Integration Record。 | +| Document Studio | CAS 保存、版本、DOCX 导入/导出、Assistant/Workflow handoff、AI suggestion 接受/拒绝/恢复。 | 没有统一 Review adapter,也没有生产级 Office Add-in/Local Bridge。仓库中的 Office/Word 概念或 Legacy PoC 不能被当作 Gate 5 完成。 | +| 模型与外部来源 | Model profile、写入型 credential、连接测试、激活/停用;法律来源设置、留存和可用性边界已有部分实现。 | 没有统一 Inference Broker 将 Matter、Source、Model、User policy 合并判定;没有凭证时必须显示 unavailable。 | +| 审计与恢复 | Workspace mutation guard、运行记录、迁移/安全/打包审计,以及跨重启恢复路径。 | 新 Proposal、policy decision、外部调用和 Word 操作仍需各自可验证审计事件。 | + +当前一级导航仍是: + +```text +Assistant | Projects | Tabular Review | Workflows | Settings +``` + +Project 内部仍是: + +```text +Documents | Assistant | Workflows | Tabular Review +``` + +目标 `Assistant / Matters / Workflows / Review / Settings` 和 Matter 内部 `Overview / Documents / Assistant / Review / Workflows / Drafts` 尚未在 `HEAD` 提交。它们需要真实路由与数据组合,不应只是文案重命名。Work Queue 是 Review 下的聚合工作模式,不是新的一级产品入口。 + +## 4. 主分支 v14 与功能分支 v15 的明确差异 + +### `origin/main`:v14 + +v1-v14 已覆盖初始 Workspace、完整性、runtime、Project ownership、Assistant、Workflow、Tabular、模型 credential/readiness、持久 Assistant events、Project source foundation、Document Studio、source retention 和 Studio suggestions。主分支没有 `matter_profiles` 或 `matter_policies`。 + +主分支的另一个关键事实是:正常后端启动会无条件组合 Legacy Aletheia,挂载十组 `/aletheia/*` router,并初始化 Legacy durable/model/voice/control 相关对象。隐藏导航并没有消除该运行时分叉。 + +### 功能分支 `HEAD`:v15 + +Phase 1 引入两个独立、严格 gate: + +```text +VERA_ENABLE_LEGACY_ROUTES=false +VERA_ENABLE_LEGACY_RUNTIME=false +``` + +只有精确小写 `true` 才启用。默认正式配置不加载 Legacy router factory 或后台 runtime;保留的 `/aletheia/*` 路径返回 404。路由 gate 与 runtime gate 相互独立,兼容测试可以按最小范围显式开启。 + +Phase 2A 的 v15 是只增不改的持久化 migration,新增: + +```text +matter_profiles +matter_policies +matter_policy_execution_locations +``` + +它保持 `Project 1 -> 0..1 MatterProfile`,没有第二套 document/chat/workflow/tabular 容器。缺少 policy 或 execution-location 为空都按 deny-all 解释;外发、外部法律源和 Word bridge 默认关闭。 + +但 v15 的已提交 profile 合同是: + +```text +matter_type = civil_litigation | commercial_dispute | contract_review | + legal_research | general +client_name, represented_role, counterparty, court, case_number, stage, +objective, risk_level, opened_at, closed_at +``` + +最新目标合同则要求通用法律 Workspace 分类: + +```text +workspaceType = general_legal | transaction | dispute | + investigation | compliance | research +clientName, matterNumber, practiceArea, jurisdiction, +representedRole, objective, status +``` + +二者不能仅靠字段改名等同:`case_number` 不等于所有 Matter 的 `matterNumber`,`stage` 不等于通用 `status`,旧 `matter_type` 也没有 investigation/compliance 的完整表达。并且 Project 已有 `cm_number`/`practice`,不应在 profile 中再产生两个竞争真源。 + +建议在保持已提交 migration 不可变的前提下,用 v16 增量校准 profile taxonomy/字段与索引;对任何已存在 v15 profile 采用显式、可审计的迁移/用户确认,不做猜测性 backfill。v15 表存在并不表示 profile runtime 或 policy enforcement 已完成。 + +## 5. 当前缺口清单 + +| 优先级 | 缺口 | 可验收的闭环定义 | +| ------ | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| P0 | Matter 语义未闭环 | 在保留 Project API/ownership 的前提下,提交一致的 Matter Profile contract、repository/service/API、原子创建、Matter list/detail/Overview 和真实计数;普通 Project 仍兼容。 | +| P0 | 统一 Proposal/Review 缺失 | 定义一个服务端 Proposal Contract;Studio suggestion、OCR warning、Workflow output、Tabular result 通过 adapter 进入同一 Review Center;接受/拒绝前重新验证 source、revision、retention、stale 与 Matter ownership。 | +| P0 | Inference Policy 仅有 schema | 一个 fail-closed Inference Broker 必须同时判定 Matter policy、Source policy、Model privacy metadata、执行位置和 user/identity policy,并记录允许或拒绝原因。 | +| P0 | 来源模型未统一 | 扩展而不是复制 Source Snapshot/Citation Anchor;支持 Project Document、Legal Authority、Conversation Transcript、Email、Manual Note、Integration Record,并阻止跨 Matter 引用。 | +| P1 | Knowledge 缺失 | 在不复制原始 Blob 的前提下实现 Personal/Matter Knowledge collection、权限和已批准 workflow/template reference;Firm Knowledge 仅定义 Port。 | +| P1 | 中国法律研究未形成真实闭环 | 至少一个真实授权 Provider:真实连接测试、用户选择来源、snapshot/anchor、研究结果到 Draft、retention/export/model-use gate;无账号环境准确 unavailable。 | +| P1 | Draft/Word 链路不完整 | 先把 Draft 收敛到 Document Studio,再实现 Office Add-in package、认证 Local Bridge、Matter 选择、来源搜索、插入引用、rewrite proposal、source check 和跨重启测试。 | +| P1 | Work Queue 缺失 | 聚合真实 Job、Proposal、Review、Workflow run 和失败/等待状态;不创建第二个 scheduler。 | +| P2 | 团队部署只有方向 | 定义 `IdentityPort`、`MatterAclPort`、`FirmKnowledgePort`、`FirmPolicyPort`、`FirmAuditPort` 及本地单用户 adapter;Firm Hub 另行实现和验收。 | +| P2 | Conversation Source 缺失 | 核心链路稳定后,按可选 source module 实现音频导入、本地转写、speaker correction、snapshot、proposal extraction 和 Review Center;不把产品变成会议软件。 | + +## 6. 重复实现与所有权冲突 + +| 能力 | 活动 Workspace 所有者 | Legacy Aletheia 重叠实现 | 处理结论 | +| -------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| 工作容器 | `projects`、Project API 与 Mike UI | Legacy matter/litigation 容器与页面 | Project 是唯一活动 ownership;Legacy 数据后续迁移,禁止新写入旧 Matter 表。 | +| 文档/来源/证据 | Workspace documents、versions、encrypted Blob、source snapshots、anchors、Studio | Legacy documents、evidence、legal research/source index | 复用 Workspace;仅抽取有价值算法,不能让新模块调用 Legacy route/table。 | +| 后台任务/Agent | 单一 Workspace `jobs`、job pump、Assistant/Workflow/Tabular runtime | Legacy durable agent/model/voice runtime | 复用 Workspace job runtime;bounded agent 以后作为 job type/事件扩展,不启动第二个 runtime。 | +| 模型入口 | Workspace model profiles、Keychain、gateway | Legacy local model scheduler/control/provider 状态 | 统一到 Inference Broker;Legacy local-model launcher 不是产品方向。 | +| 起草与审阅 | Document Studio suggestions/versions | Legacy work product、draft、review/approval 页面和 API | Studio 是 Draft 基础;统一 Review Center 使用 adapter,Legacy UI 后续删除。 | +| 法律研究 | Workspace source foundation 与现有授权来源边界 | Legacy research/issues/opinions/litigation research | 迁移可复用 provider/解析算法到明确模块;不保留两套公开产品路由。 | +| Office/Voice | 目标 Office bridge 与可选 Conversation source | Legacy Office/voice PoC 与 sidecar 资源 | 只能作为调研或算法来源;不能把保留代码宣称为生产集成。 | +| 前端信息架构 | Mike shell、Projects、Assistant、Workflows、Tabular、Settings | `/aletheia/*` shell、Matters、Work Queue、诉讼页面 | 功能分支默认隐藏且后端 404;所需概念在 Mike shell 中独立实现,不复活平行产品。 | + +这些是“仓库中存在的重复实现”,不是功能分支默认同时运行的两个产品。Phase 1 后,Legacy 默认不活动;但只要迁移账本、备份兼容、回归和算法抽取尚未完成,就不能删除 Legacy 数据或源码。 + +## 7. Legacy 的实际状态 + +### 主分支 + +- `/aletheia/*` 十组 router 在正常 composition 中无条件挂载。 +- route 构造会打开 Legacy repository/数据库句柄并创建 local control、model 和 voice 对象;bootstrap 还配置 Legacy durable runtime。 +- 因而主分支是“UI 隐藏但 runtime 仍活动”,不是单一路径产品。 + +### 功能分支 + +- `VERA_ENABLE_LEGACY_ROUTES` 和 `VERA_ENABLE_LEGACY_RUNTIME` 默认均为 false,且仅精确 `true` opt-in。 +- 默认 composition 不导入 Legacy-only route、durable、model、voice 或 demo 模块;健康状态明确报告 Legacy disabled。 +- routes=true 仍可能构造 route-owned Legacy 对象,所以它不是无害 UI 开关;runtime=true 则显式启用后台兼容生命周期。 +- Legacy frontend deep links、数据库表、sidecar、fixtures 和 package resources 仍保留;尚无完整迁移 ledger,也没有删除阶段验收。 +- Matter 与 Conversation 在 Phase 1 health 中保持 `not_configured`;v15 persistence 落地后也不能把它们升级为运行时 ready,除非对应模块和策略执行已经提交并通过 gate。 + +任何正式发布检查都应同时验证:两个 flag 为 false、Legacy health 为 disabled、保留路径返回 404、正常 Workspace 能独立完成核心流程。 + +## 8. 竞品能力族映射 + +下表只做能力族对标。Legora 与 Harvey 的描述来自官方公开页面,并按页面截至本审计日的内容概括;不据此推断其非公开架构,也不要求 Vera 像素级或命名级复制。 + +| 能力族 | Vera 已提交基础 | Legora 官方公开概念 | Harvey 官方公开概念 | Vera 的独立实现重点 | +| ---------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| Matter/文档工作空间 | Project、Documents、encrypted Blob、Project-scoped tools | [Portal](https://legora.com/product/portal) 公开描述共享 workspace、文件搜索、可控发布、角色权限与 audit trails。 | [Shared Spaces](https://www.harvey.ai/platform/shared-spaces) 公开描述 Matter Workspace、Vault/workflow/work product、guest、细粒度权限和 audit trails。 | 当前先完成单用户 Matter Profile/Overview/Document Vault 语义;客户 Portal、Shared Spaces 和多人权限不属于首版。 | +| Assistant 与可追溯问答 | Project Assistant、attachments、source snapshots、citation anchors、持久生成 | Portal 公开描述在共享文件范围内生成 grounded/cited answer 并链接精确来源。 | [Getting Started](https://help.harvey.ai/articles/getting-started-with-harvey) 公开描述 Assistant 可选择文件/knowledge source,并把答案链接到所用来源。 | 统一 source kinds、retention/model-use gate 和跨 Matter 隔离;保留 Vera 自有交互与合同。 | +| 大批量结构化审阅 | 持久 Tabular Review、列/单元格任务、导出 | [Tabular Review](https://legora.com/product/tabular-review) 公开描述大文档集表格化抽取、单元格来源/推理和复用模板。 | [Vault](https://help.harvey.ai/articles/vault) 公开描述 large document set、Review Tables、来源查看、workflow 和导出。 | 保留现有 Tabular runtime;把结果通过 Proposal adapter 送入 Review Center,而不是复制新的 review engine。 | +| 可复用工作流 | Workflow definitions/runs、持久 job、取消/重试/恢复 | Portal 公开描述可在受控 workspace 中发布/使用 workflow;目标能力族包括可复用法律流程。 | [Workflow Agents](https://help.harvey.ai/articles/assistant-workflows) 公开描述结构化、预定义、多步骤任务和可见进度。 | 复用现有 runtime,增加 source/policy binding、approved template reference 和 review output;不建立第二套 Agent Runtime。 | +| Drafting/文档内工作 | Document Studio、版本、suggestion、DOCX import/export | Portal 公开描述 built-in Editor 中协作起草合同与 memo。 | Getting Started 与 Vault 公开描述 Assistant drafting、Vault 到 draft;公开帮助还描述 Word 工作入口。 | Studio 作为唯一 Draft 真源;先完成 Review adapter,再实现安全的 Office Add-in/Local Bridge。 | +| Knowledge/Playbook | source foundation、workflow templates、Project material | Tabular Review 公开描述团队共享可复用模板;Portal 描述受控共享 work product/workflow。 | Getting Started/Library 与 Shared Spaces 公开描述 Library、playbook、institutional knowledge。 | 先做 Personal/Matter Knowledge 的逻辑引用层,不复制 Blob;Firm Knowledge 只做 Port。 | +| 协作、治理与管理 | 单用户 bearer、审计、加密、备份、model settings | Portal 公开描述 role-based access、audit trails 和分享控制。 | Shared Spaces 公开描述 resource-level permissions、ethical-wall integration、full audit trails。 | 当前诚实保持单用户;为未来 Private/China Cloud 定义 identity/ACL/policy/audit port,不伪造企业控制面。 | +| 法律研究 | Legal Authority snapshot/anchor、来源设置与部分 provider boundary | Portal 页面公开将 legal research 作为平台能力族,但未提供 Vera 所需中国授权源证明。 | Getting Started 公开描述可选外部 knowledge sources,并对答案标注来源。 | 只在一个真实授权中国 Provider 通过连接、选择、引用、留存、导出和不可用 gate 后宣称闭环。 | + +对标结论:Vera 的短板主要在“统一产品语义、人工复核、策略执行和团队边界”,不是基础 OCR、文档解析或 job runtime。最优路径是把现有可靠能力组合成 Matter 纵向任务,而不是追逐竞品页面数量。 + +## 9. 关键风险 + +| 风险 | 影响 | 控制措施 | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | +| v15 与最新 Matter contract 漂移 | 出现第二套分类/同义字段,后续 API 和 UI 固化错误语义。 | 已提交 migration 不原地改写;v16 增量校准;复用 `projects.cm_number`/`practice`;对旧 profile 显式迁移。 | +| 把 policy 表误当作 enforcement | UI 显示“安全”但模型/来源调用未真正被拒绝。 | 只有经过 Inference Broker 的 fail-closed decision 和审计事件才可报告 ready。 | +| `WorkspaceRuntime` 继续膨胀 | 模块所有权模糊,测试与启动副作用扩大。 | 使用窄 module factory/ports;composition root 只接线,不把 repository 暴露给别的模块。 | +| 封闭 enum 阻碍扩展 | `jobs.type/resource_type` 与 source kind 的 CHECK 当前只接受既有种类,未来新 source/job 无法安全写入。 | 每次通过 additive migration 扩展合同和恢复测试;禁止绕过约束或另建 job/source 表。 | +| Legacy 被误激活 | 正式包重新出现双 runtime、额外数据库句柄或模型/voice side effect。 | 保持 exact-true flags、默认 false、lazy import、404/health/packaged smoke gate。 | +| 来源/引用跨 Matter 或已过期仍被使用 | 泄密、错误引用或不合规导出。 | 所有检索、Proposal 接受、Draft/Word 和外发调用前验证 ownership、revision、tombstone、retention 和 model-use policy。 | +| 外部法律数据授权不清 | 产生虚假可用性、侵权或不可交付承诺。 | 只接真实授权 Provider;凭证/许可缺失即准确 unavailable 和 blocker,不用 fixture 代替纵向验收。 | +| 多部署模式过早耦合 | Electron 后端演化成伪 SaaS,破坏单用户可靠性。 | Individual 保持本地 adapter;Private/China Cloud 通过明确 Port 和独立服务阶段实现。 | +| 开源来源与品牌边界 | 违反 AGPL-3.0-only、Mike 归属义务或竞品知识产权边界。 | 持续维护 provenance/license 文档;只做公开能力族对标和独立 UI/代码/提示设计。 | +| 安全/恢复回归 | migration、Blob、Keychain、backup 或 restore 失败导致数据不可恢复。 | 每个 gate 保持 v1->latest、v14->latest、SQLCipher、backup/restore、tamper/fail-closed 和 packaged smoke。 | +| 用 placeholder 通过验收 | 形成“看起来完成”的 Matter/Review/Provider 页面但无真实服务端状态。 | 禁止内存假数据、静默 fallback 和 fixture 冒充真实连接;UI 必须呈现 loading/empty/error/unavailable。 | + +## 10. 明确非目标 + +### 首个 Vera Individual v2 不做 + +- 完整 Shared Spaces 或客户 Portal; +- 多租户 SaaS; +- 移动端; +- 完整 Outlook 集成; +- 全所 analytics/admin 后台; +- 自动法院提交; +- 全自主电脑操作 Agent; +- 所有中国法律数据库; +- 所有业务领域的预制 workflow; +- 完整多人同步、SSO/RBAC 服务;Gate 6 只实现 Port 和本地 adapter; +- Conversation/语音主流程;它是 Gate 7 的可选 Source 模块。 + +### 产品定位长期也不应变成 + +- “AI 律师”或面向公众的法律咨询机器人; +- 纯诉讼 Agent 或以完整民事诉讼状态机统治所有 Matter; +- 通用会议纪要软件; +- 本地大模型启动器; +- 律所 OA、完整 DMS 或通用自动化平台。 + +“本地”是部署与信任能力,不是唯一价值。Legora/Harvey 的客户协作、管理和企业治理公开概念可用于长期能力边界设计,但不能成为首版伪造多人功能的理由。 + +## 11. 分阶段差距收敛建议 + +1. **Gate 1:产品收敛。** 保持 Legacy 默认隔离;先校准 Matter contract 与 v15/v16 迁移策略,再提交 Matter Profile/API/UI/Overview;现有 Project、P0/P1 与普通 Project 兼容不变。 +2. **Gate 2:Review Center。** 先定 Proposal Contract 与服务端 revalidation,再接 Studio/OCR/Workflow/Tabular adapters;不要先做一组独立 review 数据孤岛。 +3. **Gate 3:Inference Policy 与 Knowledge。** 把 v15 policy foundation 接到唯一 Inference Broker;实现 Personal/Matter Knowledge 的引用层和模型隐私元数据。 +4. **Gate 4:中国法律研究闭环。** 只接一个真实授权 Provider,完整验证 source/citation/retention/export/model-use 与 unavailable 状态。 +5. **Gate 5:Word。** 在 Draft/Review 语义稳定后实现 Office Add-in 与认证 bridge,并验证跨重启和跨 Matter 安全。 +6. **Gate 6:团队架构准备。** 只提交 Identity/ACL/Firm Knowledge/Policy/Audit ports、本地 adapter 和 Firm Hub ADR。 +7. **Gate 7:可选 Conversation。** 核心任务稳定后再把音频作为统一 Source 接入 Proposal/Review,不复活 Legacy voice 产品路径。 + +每个 Gate 的完成结论必须来自已提交代码、真实纵向测试和准确 unavailable/blocker 状态。未提交实验、静态页面、mock、fixture 或源代码字符串检查都不能单独证明产品能力完成。 From 5cc362dbee2ccf46b19317bded62832bba9b5858 Mon Sep 17 00:00:00 2001 From: shawliu998 <260819717+shawliu998@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:59:29 +0800 Subject: [PATCH 05/10] test: align OCR audit with matter migration --- backend/src/scripts/veraWorkspaceOcrProvenanceAudit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/scripts/veraWorkspaceOcrProvenanceAudit.ts b/backend/src/scripts/veraWorkspaceOcrProvenanceAudit.ts index 209af8d..c75bb89 100644 --- a/backend/src/scripts/veraWorkspaceOcrProvenanceAudit.ts +++ b/backend/src/scripts/veraWorkspaceOcrProvenanceAudit.ts @@ -185,7 +185,7 @@ function oversizedMetadata(): DocumentChunkMetadata { function auditV1Upgrade(root: string) { assert.equal(WORKSPACE_MIGRATIONS[0]?.version, 1); - assert.equal(WORKSPACE_MIGRATIONS.at(-1)?.version, 14); + assert.equal(WORKSPACE_MIGRATIONS.at(-1)?.version, 15); const file = path.join(root, "v1.sqlite"); const projectId = randomUUID(); const documentId = randomUUID(); From 551b596d1f753a72a61463fa77032a8b26664a34 Mon Sep 17 00:00:00 2001 From: shawliu998 <260819717+shawliu998@users.noreply.github.com> Date: Thu, 16 Jul 2026 03:29:06 +0800 Subject: [PATCH 06/10] feat: add explicit matter classification migration --- backend/src/lib/workspace/migrations/index.ts | 3 + .../migrations/v16MatterClassification.ts | 80 +++ .../veraMatterProfileMigrationAudit.ts | 634 +++++++++++++++--- .../scripts/veraWorkspaceAssistantAudit.ts | 4 +- .../veraWorkspaceAssistantDurableAudit.ts | 4 +- ...WorkspaceDocumentStudioSuggestionsAudit.ts | 2 +- .../scripts/veraWorkspaceMigrationAudit.ts | 80 ++- ...aWorkspaceModelConnectionReadinessAudit.ts | 10 +- .../veraWorkspaceModelSettingsAudit.ts | 2 +- .../veraWorkspaceOcrProvenanceAudit.ts | 2 +- docs/adr/vera-product-convergence.md | 6 +- docs/architecture.md | 2 +- ...hase-2b-matter-classification-migration.md | 92 +++ docs/desktop_app.md | 8 +- docs/mike_port_manifest.md | 4 +- 15 files changed, 799 insertions(+), 134 deletions(-) create mode 100644 backend/src/lib/workspace/migrations/v16MatterClassification.ts create mode 100644 docs/convergence/phase-2b-matter-classification-migration.md diff --git a/backend/src/lib/workspace/migrations/index.ts b/backend/src/lib/workspace/migrations/index.ts index 4d81223..7ab5891 100644 --- a/backend/src/lib/workspace/migrations/index.ts +++ b/backend/src/lib/workspace/migrations/index.ts @@ -13,6 +13,7 @@ import { DOCUMENT_STUDIO_V12_MIGRATION } from "./v12DocumentStudio"; import { SOURCE_RETENTION_LIFECYCLE_V13_MIGRATION } from "./v13SourceRetentionLifecycle"; import { DOCUMENT_STUDIO_SUGGESTIONS_V14_MIGRATION } from "./v14DocumentStudioSuggestions"; import { MATTER_PROFILES_V15_MIGRATION } from "./v15MatterProfiles"; +import { MATTER_CLASSIFICATION_V16_MIGRATION } from "./v16MatterClassification"; export { detectWorkspaceDatabaseCapabilities, @@ -43,6 +44,7 @@ export { DOCUMENT_STUDIO_V12_MIGRATION } from "./v12DocumentStudio"; export { SOURCE_RETENTION_LIFECYCLE_V13_MIGRATION } from "./v13SourceRetentionLifecycle"; export { DOCUMENT_STUDIO_SUGGESTIONS_V14_MIGRATION } from "./v14DocumentStudioSuggestions"; export { MATTER_PROFILES_V15_MIGRATION } from "./v15MatterProfiles"; +export { MATTER_CLASSIFICATION_V16_MIGRATION } from "./v16MatterClassification"; export const WORKSPACE_MIGRATIONS = [ INITIAL_WORKSPACE_MIGRATION, @@ -60,4 +62,5 @@ export const WORKSPACE_MIGRATIONS = [ SOURCE_RETENTION_LIFECYCLE_V13_MIGRATION, DOCUMENT_STUDIO_SUGGESTIONS_V14_MIGRATION, MATTER_PROFILES_V15_MIGRATION, + MATTER_CLASSIFICATION_V16_MIGRATION, ] as const; diff --git a/backend/src/lib/workspace/migrations/v16MatterClassification.ts b/backend/src/lib/workspace/migrations/v16MatterClassification.ts new file mode 100644 index 0000000..beea471 --- /dev/null +++ b/backend/src/lib/workspace/migrations/v16MatterClassification.ts @@ -0,0 +1,80 @@ +import type { + WorkspaceDatabaseAdapter, + WorkspaceDatabaseCapabilities, + WorkspaceMigration, +} from "./types"; + +const MATTER_CLASSIFICATION_V16_SQL = ` +ALTER TABLE matter_profiles ADD COLUMN workspace_type TEXT + CHECK ( + workspace_type IS NULL OR ( + typeof(workspace_type) = 'text' + AND workspace_type IN ( + 'general_legal', + 'transaction', + 'dispute', + 'investigation', + 'compliance', + 'research' + ) + ) + ); + +ALTER TABLE matter_profiles ADD COLUMN jurisdiction TEXT + CHECK ( + jurisdiction IS NULL OR ( + typeof(jurisdiction) = 'text' + AND length(trim(jurisdiction)) BETWEEN 1 AND 240 + AND instr(jurisdiction, char(0)) = 0 + ) + ); + +CREATE INDEX idx_matter_profiles_workspace_type_updated + ON matter_profiles(workspace_type, updated_at DESC, project_id) + WHERE workspace_type IS NOT NULL; + +CREATE INDEX idx_matter_profiles_jurisdiction_updated + ON matter_profiles(jurisdiction, updated_at DESC, project_id) + WHERE jurisdiction IS NOT NULL; + +/* + * The column remains nullable only for rows created by a pre-v16 binary. + * Every post-v16 insert must carry the user's explicit classification. + */ +CREATE TRIGGER matter_profiles_v16_insert_requires_workspace_type +BEFORE INSERT ON matter_profiles +WHEN new.workspace_type IS NULL BEGIN + SELECT RAISE(ABORT, 'Matter workspace classification is required'); +END; + +/* + * A legacy row may move from classification_required to a selected value. + * Once classified, it cannot silently return to the ambiguous legacy state. + */ +CREATE TRIGGER matter_profiles_v16_workspace_type_one_way +BEFORE UPDATE OF workspace_type ON matter_profiles +WHEN old.workspace_type IS NOT NULL AND new.workspace_type IS NULL BEGIN + SELECT RAISE(ABORT, 'Matter workspace classification cannot be cleared'); +END; +`; + +function applyMatterClassificationV16( + database: WorkspaceDatabaseAdapter, + _capabilities: WorkspaceDatabaseCapabilities, +) { + database.exec(MATTER_CLASSIFICATION_V16_SQL); +} + +export const MATTER_CLASSIFICATION_V16_MIGRATION: WorkspaceMigration = { + version: 16, + name: "matter_profile_classification", + checksumMaterial: [ + "workspace-migration-v16", + "additive-v15-matter-classification-without-backfill", + "nullable-legacy-workspace-type-new-inserts-require-explicit-classification", + "classified-workspace-type-cannot-return-to-null", + "bounded-optional-jurisdiction-240-nul-safe", + MATTER_CLASSIFICATION_V16_SQL, + ].join("\n-- checksum boundary --\n"), + apply: applyMatterClassificationV16, +}; diff --git a/backend/src/scripts/veraMatterProfileMigrationAudit.ts b/backend/src/scripts/veraMatterProfileMigrationAudit.ts index defbd78..aad1ad1 100644 --- a/backend/src/scripts/veraMatterProfileMigrationAudit.ts +++ b/backend/src/scripts/veraMatterProfileMigrationAudit.ts @@ -10,6 +10,7 @@ import { workspaceMigrationChecksum, } from "../lib/workspace/database"; import { + MATTER_CLASSIFICATION_V16_MIGRATION, MATTER_PROFILES_V15_MIGRATION, WORKSPACE_MIGRATIONS, type WorkspaceDatabaseAdapter, @@ -19,9 +20,12 @@ import { const originalEnvironment = { ...process.env }; const root = mkdtempSync( - path.join(os.tmpdir(), "vera-matter-profile-v15-audit-"), + path.join(os.tmpdir(), "vera-matter-profile-v16-audit-"), ); const V14_MIGRATIONS = WORKSPACE_MIGRATIONS.slice(0, 14); +const V15_MIGRATIONS = WORKSPACE_MIGRATIONS.slice(0, 15); +const FROZEN_V15_CHECKSUM = + "sha256:88a7393d47909c61cdb92744467731978844897355cd86261efc6cb11b37fa5f"; const now = "2026-07-16T08:00:00.000Z"; const later = "2026-07-16T09:00:00.000Z"; @@ -46,6 +50,8 @@ function insertProject( type MatterRow = { projectId?: unknown; matterType?: unknown; + workspaceType?: unknown; + jurisdiction?: unknown; clientName?: unknown; representedRole?: unknown; counterparty?: unknown; @@ -69,8 +75,9 @@ function insertMatterProfile( `INSERT INTO matter_profiles ( project_id, matter_type, client_name, represented_role, counterparty, court, case_number, stage, objective, risk_level, - opened_at, closed_at, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + opened_at, closed_at, created_at, updated_at, + workspace_type, jurisdiction + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( input.projectId === undefined ? "project-constraints" : input.projectId, @@ -91,6 +98,8 @@ function insertMatterProfile( input.closedAt === undefined ? null : input.closedAt, input.createdAt === undefined ? now : input.createdAt, input.updatedAt === undefined ? now : input.updatedAt, + input.workspaceType === undefined ? "dispute" : input.workspaceType, + input.jurisdiction === undefined ? "CN" : input.jurisdiction, ); } @@ -208,12 +217,16 @@ function schemaNames( function auditFreshInstallAndStrictConstraints() { const database = new WorkspaceDatabase(path.join(root, "fresh.db")); try { - assert.equal(database.migration?.currentVersion, 15); + assert.equal(database.migration?.currentVersion, 16); assert.deepEqual( database.migration?.applied.map((entry) => entry.version), - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], ); assert.equal(WORKSPACE_MIGRATIONS.at(14), MATTER_PROFILES_V15_MIGRATION); + assert.equal( + WORKSPACE_MIGRATIONS.at(15), + MATTER_CLASSIFICATION_V16_MIGRATION, + ); assert.deepEqual( database .prepare("PRAGMA table_info('matter_profiles')") @@ -234,12 +247,16 @@ function auditFreshInstallAndStrictConstraints() { "closed_at", "created_at", "updated_at", + "workspace_type", + "jurisdiction", ], ); for (const index of [ "idx_matter_profiles_type_updated", "idx_matter_profiles_risk_updated", "idx_matter_profiles_case_number", + "idx_matter_profiles_workspace_type_updated", + "idx_matter_profiles_jurisdiction_updated", "idx_matter_policies_egress_updated", "idx_matter_policy_execution_locations_location", ]) { @@ -249,6 +266,8 @@ function auditFreshInstallAndStrictConstraints() { "matter_profiles_v15_update_guard", "matter_policies_v15_update_guard", "matter_policy_execution_locations_v15_immutable", + "matter_profiles_v16_insert_requires_workspace_type", + "matter_profiles_v16_workspace_type_one_way", ]) { assert.equal( schemaNames(database, "trigger").has(trigger), @@ -275,6 +294,12 @@ function auditFreshInstallAndStrictConstraints() { "low", "medium", "high", + "general_legal", + "transaction", + "dispute", + "investigation", + "compliance", + "research", ]) { assert.equal(tableSql.includes(`'${value}'`), true, value); } @@ -335,6 +360,8 @@ function auditFreshInstallAndStrictConstraints() { insertMatterProfile(database, { projectId: "project-full", matterType: "commercial_dispute", + workspaceType: "dispute", + jurisdiction: "CN / Hong Kong SAR", clientName: "Vera Client", representedRole: "Respondent", counterparty: "Example Counterparty", @@ -354,7 +381,8 @@ function auditFreshInstallAndStrictConstraints() { .prepare( `SELECT project_id, matter_type, client_name, represented_role, counterparty, court, case_number, stage, objective, - risk_level, opened_at, closed_at, created_at, updated_at + risk_level, opened_at, closed_at, created_at, updated_at, + workspace_type, jurisdiction FROM matter_profiles WHERE project_id = 'project-full'`, ) @@ -375,6 +403,8 @@ function auditFreshInstallAndStrictConstraints() { closed_at: later, created_at: now, updated_at: later, + workspace_type: "dispute", + jurisdiction: "CN / Hong Kong SAR", }, ); assert.throws( @@ -391,6 +421,30 @@ function auditFreshInstallAndStrictConstraints() { for (const matterType of ["", "Civil_litigation", "litigation", null]) { assertConstraint(database, { matterType }); } + for (const workspaceType of [ + "", + "general", + "civil_litigation", + "Dispute", + ]) { + assertConstraint(database, { workspaceType }); + } + assert.throws( + () => insertMatterProfile(database, { workspaceType: null }), + /classification is required/i, + "every new v16 Matter Profile requires an explicit classification", + ); + assert.equal( + database + .prepare( + "SELECT count(*) AS count FROM matter_profiles WHERE project_id = 'project-constraints'", + ) + .get()?.count, + 0, + ); + for (const jurisdiction of [" ", "x".repeat(241), `safe\0unsafe`]) { + assertConstraint(database, { jurisdiction }); + } for (const riskLevel of ["", "critical", "High"]) { assertConstraint(database, { riskLevel }); } @@ -433,10 +487,25 @@ function auditFreshInstallAndStrictConstraints() { database .prepare( `UPDATE matter_profiles - SET stage = 'hearing', updated_at = updated_at + SET stage = 'hearing', + workspace_type = 'transaction', + jurisdiction = 'Singapore', + updated_at = updated_at WHERE project_id = 'project-full'`, ) .run(); + assert.throws( + () => + database + .prepare( + `UPDATE matter_profiles + SET workspace_type = NULL + WHERE project_id = 'project-full'`, + ) + .run(), + /classification cannot be cleared/i, + "an explicitly classified Matter cannot return to the legacy null state", + ); assert.throws( () => database @@ -787,10 +856,10 @@ function auditV14UpgradeChecksumAndIdempotence() { const upgraded = new WorkspaceDatabase(databasePath); try { - assert.equal(upgraded.migration?.currentVersion, 15); + assert.equal(upgraded.migration?.currentVersion, 16); assert.deepEqual( upgraded.migration?.applied.map((entry) => entry.version), - [15], + [15, 16], ); assert.deepEqual( upgraded @@ -803,7 +872,7 @@ function auditV14UpgradeChecksumAndIdempotence() { .all() .map((row) => ({ ...row })), oldMigrationRows, - "v15 must not rewrite any prior migration record", + "v15 and v16 must not rewrite any prior migration record", ); assert.deepEqual( object( @@ -850,8 +919,22 @@ function auditV14UpgradeChecksumAndIdempotence() { name: MATTER_PROFILES_V15_MIGRATION.name, checksum: workspaceMigrationChecksum(MATTER_PROFILES_V15_MIGRATION), }); + const v16Record = object( + upgraded + .prepare( + `SELECT version, name, checksum + FROM workspace_schema_migrations + WHERE version = 16`, + ) + .get(), + ); + assert.deepEqual(v16Record, { + version: 16, + name: MATTER_CLASSIFICATION_V16_MIGRATION.name, + checksum: workspaceMigrationChecksum(MATTER_CLASSIFICATION_V16_MIGRATION), + }); const rerun = upgraded.runMigrations(); - assert.equal(rerun.currentVersion, 15); + assert.equal(rerun.currentVersion, 16); assert.deepEqual(rerun.applied, []); const driftedV15: WorkspaceMigration = { @@ -859,7 +942,12 @@ function auditV14UpgradeChecksumAndIdempotence() { checksumMaterial: `${MATTER_PROFILES_V15_MIGRATION.checksumMaterial}\n-- unauthorized drift`, }; assert.throws( - () => upgraded.runMigrations([...V14_MIGRATIONS, driftedV15]), + () => + upgraded.runMigrations([ + ...V14_MIGRATIONS, + driftedV15, + MATTER_CLASSIFICATION_V16_MIGRATION, + ]), /checksum drift/i, ); assert.equal( @@ -870,13 +958,29 @@ function auditV14UpgradeChecksumAndIdempotence() { .get()?.checksum, workspaceMigrationChecksum(MATTER_PROFILES_V15_MIGRATION), ); + const driftedV16: WorkspaceMigration = { + ...MATTER_CLASSIFICATION_V16_MIGRATION, + checksumMaterial: `${MATTER_CLASSIFICATION_V16_MIGRATION.checksumMaterial}\n-- unauthorized drift`, + }; + assert.throws( + () => upgraded.runMigrations([...V15_MIGRATIONS, driftedV16]), + /checksum drift/i, + ); + assert.equal( + upgraded + .prepare( + "SELECT checksum FROM workspace_schema_migrations WHERE version = 16", + ) + .get()?.checksum, + workspaceMigrationChecksum(MATTER_CLASSIFICATION_V16_MIGRATION), + ); } finally { upgraded.close(); } const reopened = new WorkspaceDatabase(databasePath); try { - assert.equal(reopened.migration?.currentVersion, 15); + assert.equal(reopened.migration?.currentVersion, 16); assert.deepEqual(reopened.migration?.applied, []); assert.equal( reopened @@ -889,7 +993,278 @@ function auditV14UpgradeChecksumAndIdempotence() { } } -function failAfterV15Apply( +function seedV15ClassificationFixture(database: WorkspaceDatabase) { + for (const [projectId, matterType] of [ + ["project-v15", "commercial_dispute"], + ["project-v15-civil", "civil_litigation"], + ["project-v15-contract", "contract_review"], + ["project-v15-research", "legal_research"], + ["project-v15-general", "general"], + ] as const) { + insertProject(database, projectId, `Preserved v15 ${matterType} Matter`); + database + .prepare( + `INSERT INTO matter_profiles ( + project_id, matter_type, client_name, represented_role, + counterparty, court, case_number, stage, objective, risk_level, + opened_at, closed_at, created_at, updated_at + ) VALUES ( + ?, ?, 'Legacy Client', 'Respondent', + 'Legacy Counterparty', 'Legacy Court', '(2026) Legacy 15', + 'intake', 'Preserve without guessing.', 'medium', ?, NULL, ?, ? + )`, + ) + .run(projectId, matterType, now, now, now); + } + database.exec(` + CREATE TABLE matter_v16_legacy_sentinel ( + id INTEGER PRIMARY KEY, + payload TEXT NOT NULL + ); + INSERT INTO matter_v16_legacy_sentinel (id, payload) + VALUES (1, 'v15-data-must-survive'); + `); +} + +function auditV15ClassificationUpgradeAndRestart() { + const databasePath = path.join(root, "v15-classification-upgrade.db"); + const v15 = new WorkspaceDatabase(databasePath, { + migrations: V15_MIGRATIONS, + }); + let v15Record: Record; + try { + assert.equal(v15.migration?.currentVersion, 15); + seedV15ClassificationFixture(v15); + v15Record = object( + v15 + .prepare( + `SELECT version, name, checksum + FROM workspace_schema_migrations + WHERE version = 15`, + ) + .get(), + ); + } finally { + v15.close(); + } + + const upgraded = new WorkspaceDatabase(databasePath); + try { + assert.equal(upgraded.migration?.currentVersion, 16); + assert.deepEqual( + upgraded.migration?.applied.map((entry) => entry.version), + [16], + ); + assert.deepEqual( + object( + upgraded + .prepare( + `SELECT matter_type, workspace_type, jurisdiction, stage + FROM matter_profiles + WHERE project_id = 'project-v15'`, + ) + .get(), + ), + { + matter_type: "commercial_dispute", + workspace_type: null, + jurisdiction: null, + stage: "intake", + }, + "v16 must not infer classification or jurisdiction from v15 metadata", + ); + assert.deepEqual( + upgraded + .prepare( + `SELECT matter_type, workspace_type, jurisdiction + FROM matter_profiles + ORDER BY matter_type`, + ) + .all() + .map((row) => ({ ...row })), + [ + "civil_litigation", + "commercial_dispute", + "contract_review", + "general", + "legal_research", + ].map((matterType) => ({ + matter_type: matterType, + workspace_type: null, + jurisdiction: null, + })), + "none of the five v15 matter_type values may guess a v16 classification", + ); + assert.deepEqual( + object( + upgraded + .prepare( + `SELECT version, name, checksum + FROM workspace_schema_migrations + WHERE version = 15`, + ) + .get(), + ), + v15Record, + "v16 must preserve the immutable v15 migration ledger record", + ); + assert.deepEqual( + object( + upgraded + .prepare( + `SELECT version, name, checksum + FROM workspace_schema_migrations + WHERE version = 16`, + ) + .get(), + ), + { + version: 16, + name: MATTER_CLASSIFICATION_V16_MIGRATION.name, + checksum: workspaceMigrationChecksum( + MATTER_CLASSIFICATION_V16_MIGRATION, + ), + }, + ); + upgraded + .prepare( + `UPDATE matter_profiles + SET stage = 'review', updated_at = ? + WHERE project_id = 'project-v15'`, + ) + .run(later); + assert.equal( + upgraded + .prepare("SELECT payload FROM matter_v16_legacy_sentinel WHERE id = 1") + .get()?.payload, + "v15-data-must-survive", + ); + } finally { + upgraded.close(); + } + + const classificationRequiredRestart = new WorkspaceDatabase(databasePath); + try { + assert.equal(classificationRequiredRestart.migration?.currentVersion, 16); + assert.deepEqual(classificationRequiredRestart.migration?.applied, []); + assert.deepEqual( + object( + classificationRequiredRestart + .prepare( + `SELECT matter_type, workspace_type, jurisdiction, stage + FROM matter_profiles + WHERE project_id = 'project-v15'`, + ) + .get(), + ), + { + matter_type: "commercial_dispute", + workspace_type: null, + jurisdiction: null, + stage: "review", + }, + ); + classificationRequiredRestart + .prepare( + `UPDATE matter_profiles + SET workspace_type = 'dispute', jurisdiction = 'PRC' + WHERE project_id = 'project-v15'`, + ) + .run(); + assert.throws( + () => + classificationRequiredRestart + .prepare( + `UPDATE matter_profiles + SET workspace_type = NULL + WHERE project_id = 'project-v15'`, + ) + .run(), + /classification cannot be cleared/i, + ); + } finally { + classificationRequiredRestart.close(); + } + + const classifiedRestart = new WorkspaceDatabase(databasePath); + try { + assert.equal(classifiedRestart.migration?.currentVersion, 16); + assert.deepEqual( + object( + classifiedRestart + .prepare( + `SELECT matter_type, workspace_type, jurisdiction + FROM matter_profiles + WHERE project_id = 'project-v15'`, + ) + .get(), + ), + { + matter_type: "commercial_dispute", + workspace_type: "dispute", + jurisdiction: "PRC", + }, + ); + assert.equal( + classifiedRestart + .prepare("SELECT payload FROM matter_v16_legacy_sentinel WHERE id = 1") + .get()?.payload, + "v15-data-must-survive", + ); + } finally { + classifiedRestart.close(); + } + + assert.throws( + () => + new WorkspaceDatabase(databasePath, { + migrations: V15_MIGRATIONS, + }), + /unknown or out-of-order migration version 16/i, + "a pre-v16 executable must fail closed rather than reinterpret v16 data", + ); + const currentBinaryRecovery = new WorkspaceDatabase(databasePath); + try { + assert.equal(currentBinaryRecovery.migration?.currentVersion, 16); + assert.deepEqual(currentBinaryRecovery.migration?.applied, []); + assert.deepEqual( + object( + currentBinaryRecovery + .prepare( + `SELECT matter_type, workspace_type, jurisdiction + FROM matter_profiles + WHERE project_id = 'project-v15'`, + ) + .get(), + ), + { + matter_type: "commercial_dispute", + workspace_type: "dispute", + jurisdiction: "PRC", + }, + ); + assert.equal( + currentBinaryRecovery + .prepare( + "SELECT checksum FROM workspace_schema_migrations WHERE version = 15", + ) + .get()?.checksum, + workspaceMigrationChecksum(MATTER_PROFILES_V15_MIGRATION), + ); + assert.equal( + currentBinaryRecovery + .prepare( + "SELECT checksum FROM workspace_schema_migrations WHERE version = 16", + ) + .get()?.checksum, + workspaceMigrationChecksum(MATTER_CLASSIFICATION_V16_MIGRATION), + ); + } finally { + currentBinaryRecovery.close(); + } +} + +function failAfterV16Apply( database: WorkspaceDatabase, onCompleteApply: () => void, ): WorkspaceDatabaseAdapter { @@ -904,9 +1279,9 @@ function failAfterV15Apply( } const wrapped: WorkspaceStatement = { run(...parameters: unknown[]) { - if (Number(parameters[0]) === 15) { + if (Number(parameters[0]) === 16) { onCompleteApply(); - throw new Error("injected fault after complete v15 apply"); + throw new Error("injected fault after complete v16 apply"); } return statement.run(...parameters); }, @@ -924,23 +1299,31 @@ function failAfterV15Apply( function auditPostApplyFailureRollsBack() { const database = new WorkspaceDatabase(path.join(root, "rollback.db"), { - migrations: V14_MIGRATIONS, + migrations: V15_MIGRATIONS, }); try { - insertProject(database, "project-rollback", "Rollback Project"); + seedV15ClassificationFixture(database); let observedCompleteApply = false; assert.throws( () => runWorkspaceMigrations( - failAfterV15Apply(database, () => { + failAfterV16Apply(database, () => { observedCompleteApply = - schemaNames(database, "table").has("matter_profiles") && - schemaNames(database, "table").has("matter_policies") && - schemaNames(database, "table").has( - "matter_policy_execution_locations", + database + .prepare("PRAGMA table_info('matter_profiles')") + .all() + .some((row) => row.name === "workspace_type") && + schemaNames(database, "index").has( + "idx_matter_profiles_workspace_type_updated", + ) && + schemaNames(database, "index").has( + "idx_matter_profiles_jurisdiction_updated", ) && schemaNames(database, "trigger").has( - "matter_profiles_v15_update_guard", + "matter_profiles_v16_insert_requires_workspace_type", + ) && + schemaNames(database, "trigger").has( + "matter_profiles_v16_workspace_type_one_way", ); }), WORKSPACE_MIGRATIONS, @@ -948,15 +1331,37 @@ function auditPostApplyFailureRollsBack() { /failed and was rolled back/i, ); assert.equal(observedCompleteApply, true); - for (const table of [ - "matter_profiles", - "matter_policies", - "matter_policy_execution_locations", - ]) { - assert.equal(schemaNames(database, "table").has(table), false, table); - } + assert.deepEqual( + database + .prepare("PRAGMA table_info('matter_profiles')") + .all() + .map((row) => String(row.name)) + .slice(-2), + ["created_at", "updated_at"], + "rolled-back v16 columns must not leak into the v15 table", + ); + assert.equal( + schemaNames(database, "index").has( + "idx_matter_profiles_workspace_type_updated", + ), + false, + ); assert.equal( - schemaNames(database, "trigger").has("matter_profiles_v15_update_guard"), + schemaNames(database, "index").has( + "idx_matter_profiles_jurisdiction_updated", + ), + false, + ); + assert.equal( + schemaNames(database, "trigger").has( + "matter_profiles_v16_insert_requires_workspace_type", + ), + false, + ); + assert.equal( + schemaNames(database, "trigger").has( + "matter_profiles_v16_workspace_type_one_way", + ), false, ); assert.equal( @@ -965,13 +1370,26 @@ function auditPostApplyFailureRollsBack() { "SELECT max(version) AS version FROM workspace_schema_migrations", ) .get()?.version, - 14, + 15, + ); + assert.deepEqual( + object( + database + .prepare( + `SELECT matter_type, stage + FROM matter_profiles + WHERE project_id = 'project-v15'`, + ) + .get(), + ), + { matter_type: "commercial_dispute", stage: "intake" }, + "v15 Matter data survives the failed v16 migration byte-logically", ); assert.equal( database - .prepare("SELECT name FROM projects WHERE id = 'project-rollback'") - .get()?.name, - "Rollback Project", + .prepare("SELECT payload FROM matter_v16_legacy_sentinel WHERE id = 1") + .get()?.payload, + "v15-data-must-survive", ); assert.deepEqual(database.prepare("PRAGMA foreign_key_check").all(), []); } finally { @@ -979,60 +1397,55 @@ function auditPostApplyFailureRollsBack() { } } -function auditSqlcipherV14Upgrade() { +function auditSqlcipherV15UpgradeAndRestart() { process.env.ALETHEIA_DATABASE_ENCRYPTION = "sqlcipher_required"; process.env.ALETHEIA_DATABASE_KEY_SOURCE = "env"; process.env.ALETHEIA_DATABASE_KEY_BASE64 = randomBytes(32).toString("base64"); const databasePath = path.join(root, "encrypted-upgrade.db"); - const v14 = new WorkspaceDatabase(databasePath, { - migrations: V14_MIGRATIONS, + const v15 = new WorkspaceDatabase(databasePath, { + migrations: V15_MIGRATIONS, }); try { - assert.equal(v14.migration?.currentVersion, 14); - assert.equal(v14.migration?.capabilities.sqlcipherEncrypted, true); - insertProject(v14, "project-encrypted", "Encrypted Project"); + assert.equal(v15.migration?.currentVersion, 15); + assert.equal(v15.migration?.capabilities.sqlcipherEncrypted, true); + seedV15ClassificationFixture(v15); } finally { - v14.close(); + v15.close(); } const upgraded = new WorkspaceDatabase(databasePath); try { - assert.equal(upgraded.migration?.currentVersion, 15); + assert.equal(upgraded.migration?.currentVersion, 16); assert.deepEqual( upgraded.migration?.applied.map((entry) => entry.version), - [15], + [16], ); assert.equal(upgraded.migration?.capabilities.sqlcipherEncrypted, true); - assert.equal( - upgraded - .prepare("SELECT name FROM projects WHERE id = 'project-encrypted'") - .get()?.name, - "Encrypted Project", - ); - insertMatterProfile(upgraded, { - projectId: "project-encrypted", - matterType: "general", - clientName: null, - representedRole: null, - counterparty: null, - court: null, - caseNumber: null, - stage: null, - objective: null, - riskLevel: null, - openedAt: null, - closedAt: null, - }); - assert.equal( - upgraded - .prepare( - "SELECT matter_type FROM matter_profiles WHERE project_id = 'project-encrypted'", - ) - .get()?.matter_type, - "general", + assert.deepEqual( + object( + upgraded + .prepare( + `SELECT matter_type, workspace_type, jurisdiction + FROM matter_profiles + WHERE project_id = 'project-v15'`, + ) + .get(), + ), + { + matter_type: "commercial_dispute", + workspace_type: null, + jurisdiction: null, + }, ); + upgraded + .prepare( + `UPDATE matter_profiles + SET workspace_type = 'investigation', jurisdiction = 'Singapore' + WHERE project_id = 'project-v15'`, + ) + .run(); insertMatterPolicy(upgraded, { - projectId: "project-encrypted", + projectId: "project-v15", externalEgressMode: "approval", audioRetentionDays: 7, allowExternalLegalSources: 1, @@ -1042,21 +1455,51 @@ function auditSqlcipherV14Upgrade() { .prepare( `INSERT INTO matter_policy_execution_locations (project_id, execution_location, created_at) - VALUES ('project-encrypted', 'firm_private', ?)`, + VALUES ('project-v15', 'firm_private', ?)`, ) .run(now); assert.equal( - policyAllowsExecutionLocation( - upgraded, - "project-encrypted", - "firm_private", - ), + policyAllowsExecutionLocation(upgraded, "project-v15", "firm_private"), true, ); assert.deepEqual(upgraded.prepare("PRAGMA foreign_key_check").all(), []); } finally { upgraded.close(); } + const reopened = new WorkspaceDatabase(databasePath); + try { + assert.equal(reopened.migration?.currentVersion, 16); + assert.deepEqual(reopened.migration?.applied, []); + assert.equal(reopened.migration?.capabilities.sqlcipherEncrypted, true); + assert.deepEqual( + object( + reopened + .prepare( + `SELECT workspace_type, jurisdiction + FROM matter_profiles + WHERE project_id = 'project-v15'`, + ) + .get(), + ), + { workspace_type: "investigation", jurisdiction: "Singapore" }, + ); + assert.equal( + policyAllowsExecutionLocation(reopened, "project-v15", "firm_private"), + true, + ); + assert.equal( + reopened + .prepare("SELECT payload FROM matter_v16_legacy_sentinel WHERE id = 1") + .get()?.payload, + "v15-data-must-survive", + ); + assert.deepEqual(reopened.prepare("PRAGMA integrity_check").all(), [ + { integrity_check: "ok" }, + ]); + assert.deepEqual(reopened.prepare("PRAGMA foreign_key_check").all(), []); + } finally { + reopened.close(); + } assert.notEqual( readFileSync(databasePath).subarray(0, 16).toString("utf8"), "SQLite format 3\0", @@ -1067,33 +1510,48 @@ try { process.env.ALETHEIA_DATABASE_ENCRYPTION = "metadata_plaintext"; assert.deepEqual( WORKSPACE_MIGRATIONS.map((migration) => migration.version), - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], ); assert.deepEqual( V14_MIGRATIONS.map((migration) => migration.version), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], ); + assert.deepEqual( + V15_MIGRATIONS.map((migration) => migration.version), + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + ); + assert.equal( + workspaceMigrationChecksum(MATTER_PROFILES_V15_MIGRATION), + FROZEN_V15_CHECKSUM, + "the committed v15 migration checksum is immutable", + ); auditFreshInstallAndStrictConstraints(); auditV14UpgradeChecksumAndIdempotence(); + auditV15ClassificationUpgradeAndRestart(); auditPostApplyFailureRollsBack(); - auditSqlcipherV14Upgrade(); + auditSqlcipherV15UpgradeAndRestart(); console.log( JSON.stringify( { ok: true, - suite: "vera-matter-profile-migration-audit-v15", - current_version: 15, + suite: "vera-matter-profile-migration-audit-v16", + current_version: 16, checks: [ - "clean SQLite v15 install", - "additive v14-to-v15 upgrade without Project backfill", + "clean SQLite v16 install", + "additive v14-to-v16 upgrade without Project backfill", + "additive v15-to-v16 classification upgrade without inferred backfill", + "legacy classification_required survives restart until explicitly selected", + "new Matter inserts require workspace_type and classified rows cannot return to null", + "strict workspace_type enum plus bounded NUL-safe jurisdiction", "strict enum, bounded text, canonical UTC, risk, and ordering checks", "Project one-to-zero-or-one cardinality and delete cascade", "profile-owned fail-closed Matter Policy defaults and normalized execution locations", "strict egress, retention, boolean, location, and cross-Project policy checks", "immutable ownership, immutable creation time, and monotonic update time", - "ordered checksum verification and idempotent rerun", - "post-DDL migration-record failure rolls back atomically", - "encrypted SQLCipher v14-to-v15 upgrade", + "frozen v15 and ordered v16 checksum verification with idempotent rerun", + "post-DDL v16 migration-record failure rolls back atomically to v15", + "pre-v16 executable fails closed and the current executable recovers", + "encrypted SQLCipher v15-to-v16 upgrade and restart", ], }, null, diff --git a/backend/src/scripts/veraWorkspaceAssistantAudit.ts b/backend/src/scripts/veraWorkspaceAssistantAudit.ts index ffc6d21..f8dd4eb 100644 --- a/backend/src/scripts/veraWorkspaceAssistantAudit.ts +++ b/backend/src/scripts/veraWorkspaceAssistantAudit.ts @@ -1019,7 +1019,7 @@ async function run() { ); const currentMigration = database.runMigrations(WORKSPACE_MIGRATIONS); - assert.equal(currentMigration.currentVersion, 15); + assert.equal(currentMigration.currentVersion, 16); markProfileReady(database, profileId); const projects = new ProjectsRepository(database); @@ -2842,7 +2842,7 @@ async function run() { migrations: WORKSPACE_MIGRATIONS, }); try { - assert.equal(reopened.migration?.currentVersion, 15); + assert.equal(reopened.migration?.currentVersion, 16); assert.equal( reopened .prepare("SELECT value FROM assistant_legacy_sentinel WHERE id=1") diff --git a/backend/src/scripts/veraWorkspaceAssistantDurableAudit.ts b/backend/src/scripts/veraWorkspaceAssistantDurableAudit.ts index cab039f..708185e 100644 --- a/backend/src/scripts/veraWorkspaceAssistantDurableAudit.ts +++ b/backend/src/scripts/veraWorkspaceAssistantDurableAudit.ts @@ -321,7 +321,7 @@ async function terminalUpgradeReplay(root: string) { const upgraded = new WorkspaceDatabase(databasePath); try { - assert.equal(upgraded.migration?.currentVersion, 15); + assert.equal(upgraded.migration?.currentVersion, 16); const replay = new ChatsRepository(upgraded).listGenerationEvents(jobId); assert.equal(replay.terminal, true); assert.deepEqual( @@ -452,7 +452,7 @@ async function run() { let database: WorkspaceDatabase | null = new WorkspaceDatabase(databasePath); let observer: WorkspaceDatabase | null = null; try { - assert.equal(database.migration?.currentVersion, 15); + assert.equal(database.migration?.currentVersion, 16); const profileId = seedEnabledProfile(database); const activeControllers = new Map(); const setup = createServices(database, { diff --git a/backend/src/scripts/veraWorkspaceDocumentStudioSuggestionsAudit.ts b/backend/src/scripts/veraWorkspaceDocumentStudioSuggestionsAudit.ts index a81a409..91cbb10 100644 --- a/backend/src/scripts/veraWorkspaceDocumentStudioSuggestionsAudit.ts +++ b/backend/src/scripts/veraWorkspaceDocumentStudioSuggestionsAudit.ts @@ -284,7 +284,7 @@ async function run() { try { process.env.ALETHEIA_DATABASE_ENCRYPTION = "metadata_plaintext"; database = new WorkspaceDatabase(databasePath); - assert.equal(database.migration?.currentVersion, 15); + assert.equal(database.migration?.currentVersion, 16); const projects = new ProjectsRepository(database); for (const [id, name] of [ [PROJECT_ID, "Suggestion Project"], diff --git a/backend/src/scripts/veraWorkspaceMigrationAudit.ts b/backend/src/scripts/veraWorkspaceMigrationAudit.ts index 9bfc7eb..8c908eb 100644 --- a/backend/src/scripts/veraWorkspaceMigrationAudit.ts +++ b/backend/src/scripts/veraWorkspaceMigrationAudit.ts @@ -26,6 +26,7 @@ import { WORKSPACE_RUNTIME_MIGRATION, DOCUMENT_STUDIO_SUGGESTIONS_V14_MIGRATION, MATTER_PROFILES_V15_MIGRATION, + MATTER_CLASSIFICATION_V16_MIGRATION, type WorkspaceDatabaseAdapter, type WorkspaceMigration, } from "../lib/workspace/migrations"; @@ -65,13 +66,14 @@ const DEFAULT_V1_TO_V10 = [ MODEL_CONNECTION_READINESS_V9_MIGRATION, ASSISTANT_DURABLE_EVENTS_V10_MIGRATION, ] as const; -const DEFAULT_V1_TO_V15 = [ +const DEFAULT_V1_TO_V16 = [ ...DEFAULT_V1_TO_V10, PROJECT_SOURCE_FOUNDATION_V11_MIGRATION, DOCUMENT_STUDIO_V12_MIGRATION, SOURCE_RETENTION_LIFECYCLE_V13_MIGRATION, DOCUMENT_STUDIO_SUGGESTIONS_V14_MIGRATION, MATTER_PROFILES_V15_MIGRATION, + MATTER_CLASSIFICATION_V16_MIGRATION, ] as const; const V9_PREFIX = DEFAULT_V1_TO_V10.slice(0, -1); @@ -541,10 +543,10 @@ try { process.env.ALETHEIA_DATABASE_ENCRYPTION = "metadata_plaintext"; assert.deepEqual( WORKSPACE_MIGRATIONS.map((migration) => migration.version), - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], - "the default registry is a contiguous v1-v15 chain", + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], + "the default registry is a contiguous v1-v16 chain", ); - assert.deepEqual(WORKSPACE_MIGRATIONS, DEFAULT_V1_TO_V15); + assert.deepEqual(WORKSPACE_MIGRATIONS, DEFAULT_V1_TO_V16); const upgradePath = path.join(root, "upgrade.db"); const v1Database = createUnmigratedDatabase("upgrade.db"); @@ -1417,11 +1419,11 @@ try { const upgraded = new WorkspaceDatabase(databasePath); try { - assert.equal(upgraded.migration?.currentVersion, 15); + assert.equal(upgraded.migration?.currentVersion, 16); assert.deepEqual( upgraded.migration?.applied.map((record) => record.version), Array.from( - { length: 15 - prefixVersion }, + { length: 16 - prefixVersion }, (_, index) => prefixVersion + index + 1, ), ); @@ -1484,9 +1486,9 @@ try { fts5: true, sqlcipherEncrypted: false, }); - assertMigrationRecords(upgraded, DEFAULT_V1_TO_V15); + assertMigrationRecords(upgraded, DEFAULT_V1_TO_V16); const rerun = upgraded.runMigrations(); - assert.equal(rerun.currentVersion, 15); + assert.equal(rerun.currentVersion, 16); assert.deepEqual(rerun.applied, []); if (prefixVersion === 6) { const driftedV8: WorkspaceMigration = { @@ -1505,19 +1507,20 @@ try { SOURCE_RETENTION_LIFECYCLE_V13_MIGRATION, DOCUMENT_STUDIO_SUGGESTIONS_V14_MIGRATION, MATTER_PROFILES_V15_MIGRATION, + MATTER_CLASSIFICATION_V16_MIGRATION, ]), /checksum drift/i, ); - assertMigrationRecords(upgraded, DEFAULT_V1_TO_V15); + assertMigrationRecords(upgraded, DEFAULT_V1_TO_V16); } } finally { upgraded.close(); } const reopened = new WorkspaceDatabase(databasePath); try { - assert.equal(reopened.migration?.currentVersion, 15); + assert.equal(reopened.migration?.currentVersion, 16); assert.deepEqual(reopened.migration?.applied, []); - assertMigrationRecords(reopened, DEFAULT_V1_TO_V15); + assertMigrationRecords(reopened, DEFAULT_V1_TO_V16); assert.equal( reopened .prepare("SELECT payload FROM aletheia_phase1_sentinel WHERE id = 1") @@ -1532,10 +1535,10 @@ try { const newInstall = createUnmigratedDatabase("new-install.db"); try { const migration = runWorkspaceMigrations(newInstall, WORKSPACE_MIGRATIONS); - assert.equal(migration.currentVersion, 15); + assert.equal(migration.currentVersion, 16); assert.deepEqual( migration.applied.map((record) => record.version), - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], ); assert.ok(schemaNames(newInstall, "table").has("workspace_blob_records")); assert.ok( @@ -1587,7 +1590,33 @@ try { assert.ok( schemaNames(newInstall, "table").has("matter_policy_execution_locations"), ); - assertMigrationRecords(newInstall, DEFAULT_V1_TO_V15); + assert.ok( + columnNames(newInstall, "matter_profiles").includes("workspace_type"), + ); + assert.ok( + columnNames(newInstall, "matter_profiles").includes("jurisdiction"), + ); + assert.ok( + schemaNames(newInstall, "index").has( + "idx_matter_profiles_workspace_type_updated", + ), + ); + assert.ok( + schemaNames(newInstall, "index").has( + "idx_matter_profiles_jurisdiction_updated", + ), + ); + assert.ok( + schemaNames(newInstall, "trigger").has( + "matter_profiles_v16_insert_requires_workspace_type", + ), + ); + assert.ok( + schemaNames(newInstall, "trigger").has( + "matter_profiles_v16_workspace_type_one_way", + ), + ); + assertMigrationRecords(newInstall, DEFAULT_V1_TO_V16); } finally { newInstall.close(); } @@ -1808,7 +1837,7 @@ try { ); const encryptedGateUpgrade = new WorkspaceDatabase(plaintextGatePath); try { - assert.equal(encryptedGateUpgrade.migration?.currentVersion, 15); + assert.equal(encryptedGateUpgrade.migration?.currentVersion, 16); assert.equal( encryptedGateUpgrade.migration?.capabilities.sqlcipherEncrypted, true, @@ -1840,7 +1869,7 @@ try { .get()?.payload, "legacy-data-must-survive", ); - assertMigrationRecords(encryptedGateUpgrade, DEFAULT_V1_TO_V15); + assertMigrationRecords(encryptedGateUpgrade, DEFAULT_V1_TO_V16); } finally { encryptedGateUpgrade.close(); } @@ -1888,12 +1917,12 @@ try { encryptedDatabase.migration?.capabilities.sqlcipherEncrypted, true, ); - assert.equal(encryptedDatabase.migration?.currentVersion, 15); + assert.equal(encryptedDatabase.migration?.currentVersion, 16); assert.equal( encryptedDatabase .prepare("SELECT count(*) AS count FROM workspace_schema_migrations") .get()?.count, - 15, + 16, ); } finally { encryptedDatabase.close(); @@ -1920,12 +1949,12 @@ try { true, "the wrapper-owned migration entrypoint preserves exact SQLCipher attestation", ); - assert.equal(encryptedManualRun.currentVersion, 15); + assert.equal(encryptedManualRun.currentVersion, 16); assert.equal( encryptedManualDatabase .prepare("SELECT count(*) AS count FROM workspace_schema_migrations") .get()?.count, - 15, + 16, ); } finally { encryptedManualDatabase.close(); @@ -1939,13 +1968,13 @@ try { JSON.stringify( { ok: true, - suite: "vera-workspace-migration-audit-v15", - current_version: 15, + suite: "vera-workspace-migration-audit-v16", + current_version: 16, encrypted_driver: encryptedStatus!.encrypted, checks: [ "legacy opaque and incomplete-matrix fixtures remain explicit v1-v6", - "runtime-valid v1, v2, v3, and v6 prefixes upgrade through default v15", - "clean default v15 install", + "runtime-valid v1, v2, v3, and v6 prefixes upgrade through default v16", + "clean default v16 install", "v9 connection readiness schema applies on SQLite and SQLCipher", "v10 immutable Assistant durable event outbox applies on SQLite and SQLCipher", "v11 immutable Project source snapshots and citation anchors", @@ -1953,6 +1982,7 @@ try { "v13 source retention lifecycle and fail-closed access metadata", "v14 immutable Document Studio AI suggestions", "v15 optional Matter Profiles and fail-closed Matter Policies", + "v16 explicit Matter workspace classification and jurisdiction without legacy backfill", "ordered SHA-256 checksums and idempotent rerun", "failed migration DDL and record roll back atomically", "legacy Aletheia sentinel table and row preserved", @@ -1970,7 +2000,7 @@ try { "cross-project, ownership, cycle, type, and cell triggers", "SQLite and SQLCipher integrity checks", "plaintext destructive migration fails byte-exact before SQLCipher", - "offline SQLCipher migration enables trusted default v7-v15 upgrade", + "offline SQLCipher migration enables trusted default v7-v16 upgrade", "encrypted v6 missing matrix fails transactionally before v7 markers", "encrypted v6 opaque tabular IDs fail the frozen validator transactionally", ], diff --git a/backend/src/scripts/veraWorkspaceModelConnectionReadinessAudit.ts b/backend/src/scripts/veraWorkspaceModelConnectionReadinessAudit.ts index a07404b..92528c2 100644 --- a/backend/src/scripts/veraWorkspaceModelConnectionReadinessAudit.ts +++ b/backend/src/scripts/veraWorkspaceModelConnectionReadinessAudit.ts @@ -189,10 +189,10 @@ function auditUpgradeAndConnectionRevisionSemantics() { const upgraded = new WorkspaceDatabase(databasePath); try { - assert.equal(upgraded.migration?.currentVersion, 15); + assert.equal(upgraded.migration?.currentVersion, 16); assert.deepEqual( upgraded.migration?.applied.map((entry) => entry.version), - [9, 10, 11, 12, 13, 14, 15], + [9, 10, 11, 12, 13, 14, 15, 16], ); assert.equal( columnNames(upgraded, "model_profiles").includes("connection_revision"), @@ -377,10 +377,10 @@ function insertConnectionResult( function auditNewInstallAndStrictConstraints() { const database = new WorkspaceDatabase(path.join(root, "new-install.db")); try { - assert.equal(database.migration?.currentVersion, 15); + assert.equal(database.migration?.currentVersion, 16); assert.deepEqual( database.migration?.applied.map((entry) => entry.version), - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], ); assert.equal( WORKSPACE_MIGRATIONS.at(8), @@ -719,7 +719,7 @@ try { process.env.ALETHEIA_DATABASE_ENCRYPTION = "metadata_plaintext"; assert.deepEqual( WORKSPACE_MIGRATIONS.map((migration) => migration.version), - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], ); assert.deepEqual( V8_MIGRATIONS.map((migration) => migration.version), diff --git a/backend/src/scripts/veraWorkspaceModelSettingsAudit.ts b/backend/src/scripts/veraWorkspaceModelSettingsAudit.ts index 7b221e3..90f6e4c 100644 --- a/backend/src/scripts/veraWorkspaceModelSettingsAudit.ts +++ b/backend/src/scripts/veraWorkspaceModelSettingsAudit.ts @@ -4257,7 +4257,7 @@ try { checksumMaterial: migration.checksumMaterial, })); assert.deepEqual(compiledGraph.migrations, currentChecksums); - assert.equal(compiledGraph.runtime.currentVersion, 15); + assert.equal(compiledGraph.runtime.currentVersion, 16); assert.deepEqual( compiledGraph.runtime.appliedVersions, WORKSPACE_MIGRATIONS.map((migration) => migration.version), diff --git a/backend/src/scripts/veraWorkspaceOcrProvenanceAudit.ts b/backend/src/scripts/veraWorkspaceOcrProvenanceAudit.ts index c75bb89..d44afa1 100644 --- a/backend/src/scripts/veraWorkspaceOcrProvenanceAudit.ts +++ b/backend/src/scripts/veraWorkspaceOcrProvenanceAudit.ts @@ -185,7 +185,7 @@ function oversizedMetadata(): DocumentChunkMetadata { function auditV1Upgrade(root: string) { assert.equal(WORKSPACE_MIGRATIONS[0]?.version, 1); - assert.equal(WORKSPACE_MIGRATIONS.at(-1)?.version, 15); + assert.equal(WORKSPACE_MIGRATIONS.at(-1)?.version, 16); const file = path.join(root, "v1.sqlite"); const projectId = randomUUID(); const documentId = randomUUID(); diff --git a/docs/adr/vera-product-convergence.md b/docs/adr/vera-product-convergence.md index 4c6947f..2a71cf6 100644 --- a/docs/adr/vera-product-convergence.md +++ b/docs/adr/vera-product-convergence.md @@ -336,9 +336,9 @@ renames, bulk inferred profile creation, and in-place deletion are prohibited. Committed migration v15 remains immutable. Its litigation-oriented `matter_type` and dormant Matter Policy foundation are transitional storage, not the final public workspace taxonomy and not proof that inference is -permitted. A later additive classification migration introduces the broader -user-selected workspace classification and jurisdiction without guessing from -v15 values. Missing classification remains an explicit capability state. +permitted. Additive migration v16 introduces the broader user-selected +workspace classification and jurisdiction without guessing from v15 values. +Missing classification remains an explicit capability state. Legacy migration is a bounded import, not runtime convergence: diff --git a/docs/architecture.md b/docs/architecture.md index 253101b..dfa1f50 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,7 +35,7 @@ Vera.app (Electron main process) `- controlled native backup/restore/log/diagnostic operations Local persistence - |- SQLCipher Workspace metadata (migrations v1-v15) + |- SQLCipher Workspace metadata (migrations v1-v16) |- AES-256-GCM encrypted originals, extracted content, and exports |- FTS5 project/document retrieval |- authenticated encrypted backups and restore journal diff --git a/docs/convergence/phase-2b-matter-classification-migration.md b/docs/convergence/phase-2b-matter-classification-migration.md new file mode 100644 index 0000000..4beda95 --- /dev/null +++ b/docs/convergence/phase-2b-matter-classification-migration.md @@ -0,0 +1,92 @@ +# Phase 2B — Matter Classification Migration + +Date: 2026-07-16 + +## Completed + +Workspace migration v16, `matter_profile_classification`, adds the Gate 1 +classification contract to the existing v15 `matter_profiles` table. The +migration is additive: v15 and every earlier migration remain byte-for-byte +immutable, and no Project or Matter row is inferred or backfilled. + +## Schema contract + +Two nullable columns are appended: + +- `workspace_type`: `general_legal`, `transaction`, `dispute`, + `investigation`, `compliance`, or `research`; +- `jurisdiction`: optional trimmed non-empty text, at most 240 characters and + with embedded NUL rejected. + +`workspace_type` is nullable only for a row created before v16. Such a row is +the persisted `classification_required` state. The migration deliberately does +not map any v15 `matter_type` value to the broader v16 taxonomy and leaves +`jurisdiction` null. + +The database enforces the one-way compatibility boundary: + +- a new `matter_profiles` insert without `workspace_type` fails; +- a legacy null classification may be set explicitly; +- once non-null, `workspace_type` cannot be cleared back to null; +- invalid classifications, blank/oversized jurisdictions and NUL-containing + jurisdictions fail at the schema boundary. + +Partial indexes cover classified workspace-type and jurisdiction lookups. The +v16 insert and one-way update triggers are checksum-bound with both column +definitions and indexes. + +Because the immutable v15 table still requires `matter_type`, the Gate 1 +compatibility writer uses the fixed non-semantic value `general` for a newly +created row. That is an application compatibility rule, not a migration-time +guess: v16 never rewrites an existing v15 `matter_type` and does not expose it +as the public classification. + +## Security and data-integrity implications + +- Project remains the sole ownership and cascade boundary. +- Classification is explicit user-selected metadata, not an AI inference. +- A missing classification is visible and fail-closed; it is not silently + treated as `general_legal`. +- The migration is transactional, ordered, SHA-256 checksum-recorded and + idempotent on both SQLite and SQLCipher. +- Existing Matter Policy defaults remain deny-all and are not changed by v16. + +## Rollback and recovery + +There is no destructive down migration. A pre-v16 executable sees migration +ledger version 16 and fails closed with an unknown-version error; it must not +delete the v16 ledger row or reinterpret the appended columns. + +Executable rollback therefore requires either a compatibility build that +retains the v16 migration registry while leaving the additive fields unused, +or restoration of the verified encrypted pre-v16 backup. A current v16 binary +can reopen the same database after a rejected old-binary launch without data or +checksum changes. + +An injected failure after the complete v16 DDL but before the migration-ledger +record rolls the columns, indexes and triggers back atomically to the intact +v15 schema and data. + +## Validation + +The focused and full Workspace migration audits cover: + +```text +clean SQLite v16 install and strict constraints +v14-to-v16 preservation upgrade with Legacy sentinel +all five v15 matter_type values preserved with NULL v16 classification +new-insert classification trigger and one-way classified-state trigger +bounded, NUL-safe jurisdiction and exact workspace_type enum +frozen v15 checksum plus v16 checksum drift and idempotent rerun +v16 DDL/ledger injected rollback to intact v15 +classification_required and classified restart persistence +pre-v16 executable fail-closed and current-binary recovery +encrypted SQLCipher v15-to-v16 upgrade, integrity check and restart +``` + +## Scope boundary + +This slice changes persistence only. The separate Gate 1 Matter +contracts/repository/service/API and renderer slices consume this schema while +preserving its explicit-classification boundary; they do not make v15 fields +public or infer values for historical rows. diff --git a/docs/desktop_app.md b/docs/desktop_app.md index 3a0bee5..5cbfea9 100644 --- a/docs/desktop_app.md +++ b/docs/desktop_app.md @@ -136,11 +136,13 @@ credentials, proxy variables, or Node injection flags. ## Local persistence and credentials -Workspace schema migrations currently run through v15 -(`v15MatterProfiles`). V11 adds the Project source foundation, v12 adds +Workspace schema migrations currently run through v16 +(`v16MatterClassification`). V11 adds the Project source foundation, v12 adds Document Studio, v13 adds source-retention lifecycle enforcement, v14 adds reviewable Document Studio suggestions, and v15 adds the optional one-to-one -Matter Profile plus fail-closed Matter Policy foundation. Projects, folders, document +Matter Profile plus fail-closed Matter Policy foundation. V16 adds explicit +workspace classification and bounded jurisdiction while leaving existing v15 +rows unclassified instead of guessing a mapping. Projects, folders, document versions, chats, messages, jobs, workflow definitions/runs/step runs, Tabular Reviews/cells, model-profile metadata, durable Assistant events, source provenance, and Studio state use the local Workspace database. The packaged diff --git a/docs/mike_port_manifest.md b/docs/mike_port_manifest.md index f4626ed..894f0df 100644 --- a/docs/mike_port_manifest.md +++ b/docs/mike_port_manifest.md @@ -59,7 +59,7 @@ Mike UI 的使用已经获得授权。仓库仍按 AGPL-3.0-only 基线保留来 ## 2. 当前工作树事实快照 - 固定 Mike commit 已在本地 Git 对象库中,移植文件保留 source-lock/provenance。 -- `backend/src/lib/workspace/` 已收敛到 additive SQLCipher migrations v1-v15、repositories/services、加密 Blob、下载 capability、FTS、持久 jobs/events 和统一 runtime;v15 以一对一可选扩展保留 Project 技术边界。 +- `backend/src/lib/workspace/` 已收敛到 additive SQLCipher migrations v1-v16、repositories/services、加密 Blob、下载 capability、FTS、持久 jobs/events 和统一 runtime;v15 以一对一可选扩展保留 Project 技术边界,v16 在不猜测旧值的前提下增加显式 Matter 分类和 jurisdiction。 - `backend/src/index.ts` 是薄入口;`backend/src/veraApplication.ts` 是唯一 composition root。Legacy `/aletheia` 保留,Workspace API 只在 `/api/v1` 挂载一次。 - 同一个持久 job pump 执行 `document_parse`、`assistant_generate`、`workflow_run` 和 `tabular_cell`;没有第二套前端假执行器或内存任务状态机。 - Assistant、Projects、Tabular Review、Workflows、Settings 的活动页面与真实本地 API 已接通;主导航不再进入 `/aletheia/*`。 @@ -152,7 +152,7 @@ Mike UI 的使用已经获得授权。仓库仍按 AGPL-3.0-only 基线保留来 | renderer transport | `veraRuntime.ts`、`veraApi.ts`、`veraSse.ts`、`veraWireTypes.ts`;token 由 preload 提供 | `source-complete` | | preload | 只暴露必要 token、backup/restore、日志、凭据和受控 native 能力;`window.aletheiaDesktop` 名称仅作为 Legacy 兼容边界 | `source-complete; packaged-complete` | | downloads | `/api/v1/downloads/:token` + 短期 capability | `source-complete; packaged-complete` | -| schema | additive SQLCipher migrations v1-v15;不导入 Mike Postgres/RLS schema | `source-complete` | +| schema | additive SQLCipher migrations v1-v16;不导入 Mike Postgres/RLS schema | `source-complete` | ## 10. 明确排除的云端和多用户依赖 From c2e29ef86fb4ee08b3516ef06fb45b00507ed9fe Mon Sep 17 00:00:00 2001 From: shawliu998 <260819717+shawliu998@users.noreply.github.com> Date: Thu, 16 Jul 2026 03:29:19 +0800 Subject: [PATCH 07/10] feat: compose matter profile workspace --- backend/package.json | 5 + backend/src/lib/workspace/jobs/pump.ts | 8 +- backend/src/lib/workspace/jobs/types.ts | 31 + .../lib/workspace/repositories/projects.ts | 82 + backend/src/lib/workspace/runtime.ts | 59 +- backend/src/lib/workspace/services/jobs.ts | 106 +- .../services/projectInferenceScope.ts | 180 ++ .../lib/workspace/services/workflowRuntime.ts | 2 +- backend/src/matter/inferencePolicy.ts | 105 + backend/src/matter/profile/contracts.ts | 520 ++++ backend/src/matter/profile/index.ts | 68 + .../src/matter/profile/overviewRepository.ts | 267 ++ backend/src/matter/profile/repository.ts | 192 ++ backend/src/matter/profile/router.ts | 274 +++ backend/src/matter/profile/service.ts | 324 +++ .../scripts/veraMatterInferencePolicyAudit.ts | 175 ++ .../scripts/veraMatterProfileModuleAudit.ts | 2138 +++++++++++++++++ .../scripts/veraWorkspaceApplicationAudit.ts | 99 + .../veraWorkspaceAssistantExecutionAudit.ts | 275 ++- backend/src/veraApplication.ts | 58 +- backend/tsconfig.build.json | 2 + .../phase-2b-matter-profile-api.md | 216 ++ 22 files changed, 5148 insertions(+), 38 deletions(-) create mode 100644 backend/src/lib/workspace/services/projectInferenceScope.ts create mode 100644 backend/src/matter/inferencePolicy.ts create mode 100644 backend/src/matter/profile/contracts.ts create mode 100644 backend/src/matter/profile/index.ts create mode 100644 backend/src/matter/profile/overviewRepository.ts create mode 100644 backend/src/matter/profile/repository.ts create mode 100644 backend/src/matter/profile/router.ts create mode 100644 backend/src/matter/profile/service.ts create mode 100644 backend/src/scripts/veraMatterInferencePolicyAudit.ts create mode 100644 backend/src/scripts/veraMatterProfileModuleAudit.ts create mode 100644 docs/convergence/phase-2b-matter-profile-api.md diff --git a/backend/package.json b/backend/package.json index 74e93ce..baae052 100644 --- a/backend/package.json +++ b/backend/package.json @@ -53,6 +53,11 @@ "test:aletheia:malware-scanner": "tsx src/scripts/aletheiaMalwareScannerAudit.ts", "test:aletheia:encryption": "tsx src/scripts/aletheiaLocalEncryptionAudit.ts", "test:aletheia:sqlcipher": "tsx src/scripts/aletheiaSqlcipherIntegrationAudit.ts", + "test:vera:matter:migration": "tsx src/scripts/veraMatterProfileMigrationAudit.ts", + "test:vera:matter:module": "tsx src/scripts/veraMatterProfileModuleAudit.ts", + "test:vera:matter:inference": "tsx src/scripts/veraMatterInferencePolicyAudit.ts", + "test:vera:matter": "npm run test:vera:matter:migration && npm run test:vera:matter:module && npm run test:vera:matter:inference", + "test:vera:convergence": "npm run test:vera:matter && npm run test:workspace:p1-convergence", "test:workspace:migrations": "tsx src/scripts/veraWorkspaceMigrationAudit.ts", "test:workspace:source-foundation": "tsx src/scripts/veraWorkspaceSourceFoundationAudit.ts", "test:workspace:source-retention": "tsx src/scripts/veraWorkspaceSourceRetentionAudit.ts", diff --git a/backend/src/lib/workspace/jobs/pump.ts b/backend/src/lib/workspace/jobs/pump.ts index 43aac7d..c6e35c7 100644 --- a/backend/src/lib/workspace/jobs/pump.ts +++ b/backend/src/lib/workspace/jobs/pump.ts @@ -4,7 +4,11 @@ import { WorkspaceJobRuntime, type WorkspaceJobHandlers, } from "../services/jobs"; -import { WORKSPACE_JOB_TYPES, type WorkspaceJobType } from "../jobs/types"; +import { + WORKSPACE_JOB_TYPES, + type ProjectInferenceScopeResolver, + type WorkspaceJobType, +} from "../jobs/types"; import type { WorkspaceJobStoredRecord } from "../repositories/jobs"; export interface WorkspaceJobPumpTimerHandle { @@ -36,6 +40,7 @@ export interface WorkspaceJobPumpOptions { drainTimeoutMs?: number; leaseOwner?: string; leaseDurationMs?: number; + inferenceScopeResolver?: ProjectInferenceScopeResolver; } export interface WorkspaceJobPumpStartResult { @@ -191,6 +196,7 @@ export class WorkspaceJobPump { leaseDurationMs: options.leaseDurationMs, recoveryMode: "fenced", allowedJobTypes, + inferenceScopeResolver: options.inferenceScopeResolver, manageProcessSignals: false, timer: this.timer, }, diff --git a/backend/src/lib/workspace/jobs/types.ts b/backend/src/lib/workspace/jobs/types.ts index 6857737..31cc093 100644 --- a/backend/src/lib/workspace/jobs/types.ts +++ b/backend/src/lib/workspace/jobs/types.ts @@ -12,3 +12,34 @@ export { type WorkspaceJobType, type WorkspaceJobValueProjection, } from "../jobContractV7"; + +import type { WorkspaceJobStoredRecord } from "../jobPersistenceV7"; + +export const PROJECT_INFERENCE_JOB_TYPES = [ + "assistant_generate", + "workflow_run", + "tabular_cell", +] as const; + +export type ProjectInferenceJobType = + (typeof PROJECT_INFERENCE_JOB_TYPES)[number]; + +/** Immutable scope captured before an inference handler can lose its owner. */ +export type WorkspaceInferenceActivityScope = Readonly< + | { + jobId: string; + type: ProjectInferenceJobType; + scope: "project"; + projectId: string; + } + | { + jobId: string; + type: ProjectInferenceJobType; + scope: "global" | "unresolved"; + projectId: null; + } +>; + +export type ProjectInferenceScopeResolver = ( + job: WorkspaceJobStoredRecord, +) => WorkspaceInferenceActivityScope | null; diff --git a/backend/src/lib/workspace/repositories/projects.ts b/backend/src/lib/workspace/repositories/projects.ts index 32b9719..7a60572 100644 --- a/backend/src/lib/workspace/repositories/projects.ts +++ b/backend/src/lib/workspace/repositories/projects.ts @@ -1,4 +1,5 @@ import type { WorkspaceBlobLocator } from "../blobStore"; +import { z } from "zod"; import type { WorkspaceDatabaseAdapter } from "../database"; import { assertNoActiveProjectWorkflowForFolder, @@ -13,6 +14,8 @@ import { type PageRequest, } from "../pagination"; import type { Project, ProjectFolder } from "../types"; +import type { WorkspaceInferenceActivityScope } from "../jobs/types"; +import { WorkspaceIdSchema } from "../workspacePersistencePrimitivesV1"; type Row = Record; @@ -37,6 +40,14 @@ export type ActiveProjectResourceJob = { status: "queued" | "running"; }; +/** Narrow read boundary used by the Matter conversion coordinator. */ +export interface ProjectInferenceActivityReadPort { + hasBlockingInferenceJobs( + projectId: string, + activeScopes: readonly WorkspaceInferenceActivityScope[], + ): boolean; +} + export type StagedProjectBlob = { recordId: string; quarantineId: string; @@ -101,6 +112,33 @@ const PROJECT_JOB_SCOPE_PREDICATE = ` OR (j.resource_type = 'tabular_cell' AND j.resource_id IN (SELECT id FROM project_cells)) OR (j.resource_type = 'workflow_run' AND j.resource_id IN (SELECT id FROM project_runs))`; +const ProjectInferenceActivityScopeSchema = z.discriminatedUnion("scope", [ + z + .object({ + jobId: WorkspaceIdSchema, + type: z.enum(["assistant_generate", "workflow_run", "tabular_cell"]), + scope: z.literal("project"), + projectId: WorkspaceIdSchema, + }) + .strict(), + z + .object({ + jobId: WorkspaceIdSchema, + type: z.enum(["assistant_generate", "workflow_run", "tabular_cell"]), + scope: z.literal("global"), + projectId: z.null(), + }) + .strict(), + z + .object({ + jobId: WorkspaceIdSchema, + type: z.enum(["assistant_generate", "workflow_run", "tabular_cell"]), + scope: z.literal("unresolved"), + projectId: z.null(), + }) + .strict(), +]); + const FOLDER_SCOPE_CTE = ` WITH RECURSIVE folder_subtree(id) AS ( SELECT id FROM project_subfolders WHERE id = ? @@ -358,6 +396,50 @@ export class ProjectsRepository { }); } + /** + * Uses the complete Project job-ownership graph. It deliberately returns + * only presence: Matter conversion must not expose, mutate, or cancel the + * user's queued/running or still-executing inference work. + * + * `activeScopes` is a bounded snapshot frozen before each handler starts. + * It closes the cancellation/owner-deletion window where durable rows have + * become terminal or disappeared but a provider call has not unwound yet. + */ + hasBlockingInferenceJobs( + projectIdValue: string, + activeScopeValues: readonly WorkspaceInferenceActivityScope[], + ): boolean { + return this.safe(() => { + const projectId = WorkspaceIdSchema.parse(projectIdValue); + let frozenScopeBlocks = false; + for (const value of activeScopeValues) { + const parsed = ProjectInferenceActivityScopeSchema.safeParse(value); + if (!parsed.success || parsed.data.scope === "unresolved") { + frozenScopeBlocks = true; + continue; + } + if ( + parsed.data.scope === "project" && + parsed.data.projectId === projectId + ) { + frozenScopeBlocks = true; + } + } + const row = this.database + .prepare( + `${PROJECT_JOB_SCOPE_CTE} + SELECT 1 AS present + FROM jobs j + WHERE j.type IN ('assistant_generate', 'workflow_run', 'tabular_cell') + AND j.status IN ('queued', 'running') + AND (${PROJECT_JOB_SCOPE_PREDICATE}) + LIMIT 1`, + ) + .get(projectId); + return frozenScopeBlocks || row?.present === 1 || row?.present === 1n; + }); + } + assertPermanentDelete(id: string, confirmName: string) { return this.safe(() => { const value = this.require(id); diff --git a/backend/src/lib/workspace/runtime.ts b/backend/src/lib/workspace/runtime.ts index 4a04635..2d5e482 100644 --- a/backend/src/lib/workspace/runtime.ts +++ b/backend/src/lib/workspace/runtime.ts @@ -20,6 +20,15 @@ import type { WorkspaceProjectSourcesV1Port, } from "../../routes/workspaceProjectSourcesV1"; import type { WorkspaceTabularV1RuntimePort } from "../../routes/workspaceTabularV1"; +import { + createMatterProfileModule, + type MatterProfileModule, +} from "../../matter/profile"; +import { + MatterInferencePolicyGate, + MatterPolicyAssistantToolPort, + MatterPolicyWorkflowStepExecutor, +} from "../../matter/inferencePolicy"; import type { WorkspaceV1Context, WorkspaceV1DocumentCapability, @@ -117,6 +126,7 @@ import { WorkspaceJobsService, } from "./services/jobs"; import { WorkspaceJobEnqueuerAdapter } from "./services/jobEnqueuer"; +import { CanonicalProjectInferenceScopeResolver } from "./services/projectInferenceScope"; import { ProjectsService } from "./services/projects"; import type { CredentialStorePort } from "./services/credentialStore"; import { ModelProfilesService } from "./services/modelProfiles"; @@ -243,6 +253,7 @@ export type WorkspaceRuntimeDependencies = { credentialStore?: CredentialStorePort; modelProviderRegistry?: WorkspaceModelProviderRegistry; modelSettings?: WorkspaceModelSettingsRuntime; + matterProfiles?: MatterProfileModule; modelProviderOptions?: WorkspaceModelProviderRegistryOptions; allowLocalDevelopmentModelBaseUrl?: boolean; assistantModel?: AssistantModelPort; @@ -408,6 +419,7 @@ export class WorkspaceRuntime readonly modelSettings: WorkspaceModelSettingsRuntime; readonly chats: WorkspaceChatsV1Port; readonly tabular: WorkspaceTabularV1RuntimePort; + readonly matterProfiles: MatterProfileModule; private readonly documentService: WorkspaceDocumentsService; private readonly documentRepository: WorkspaceDocumentsRepository; private readonly documentOcrSummary: WorkspaceDocumentOcrSummaryService; @@ -512,6 +524,13 @@ export class WorkspaceRuntime }; const projectsRepository = dependencies.projectRepository ?? new ProjectsRepository(this.database); + this.matterProfiles = + dependencies.matterProfiles ?? + createMatterProfileModule(this.database, projectsRepository, { + activeInferenceScopes: () => this.abortRegistry.activeInferenceScopes(), + acceptingRequests: () => this.started && !this.draining && !this.closed, + }); + const matterInferencePolicy = new MatterInferencePolicyGate(this.database); this.projects = dependencies.projects ?? new ProjectsService(projectsRepository, this.blobs, { @@ -616,7 +635,8 @@ export class WorkspaceRuntime projectId: string; documentId: string; versionId: string; - }) => + }) => { + matterInferencePolicy.assertProjectModelUse(input.projectId); this.assertStudioVersionRetention({ ...input, action: "model_use", @@ -625,17 +645,20 @@ export class WorkspaceRuntime // licensed as local_only; Project user documents remain permitted. modelExecution: "unknown", }); - const assistantTools = + }; + const assistantTools = new MatterPolicyAssistantToolPort( dependencies.assistantTools ?? - new WorkspaceAssistantDocumentTools( - this.database, - chatsRepository, - new AssistantRetrievalRepository(this.database), - { - studioSuggestions: this.documentStudioService, - assertModelUse: assertDocumentModelUse, - }, - ); + new WorkspaceAssistantDocumentTools( + this.database, + chatsRepository, + new AssistantRetrievalRepository(this.database), + { + studioSuggestions: this.documentStudioService, + assertModelUse: assertDocumentModelUse, + }, + ), + matterInferencePolicy, + ); const assistantRuntime = assistantModel ? new AssistantRuntimeService( chatsRepository, @@ -645,7 +668,7 @@ export class WorkspaceRuntime ) : null; this.assistantGenerationEnabled = assistantRuntime !== null; - const workflowExecutor = + const unguardedWorkflowExecutor = dependencies.workflowExecutor ?? (assistantModel ? new WorkspaceWorkflowStepExecutor( @@ -656,6 +679,12 @@ export class WorkspaceRuntime { assertModelUse: assertDocumentModelUse }, ) : null); + const workflowExecutor = unguardedWorkflowExecutor + ? new MatterPolicyWorkflowStepExecutor( + unguardedWorkflowExecutor, + matterInferencePolicy, + ) + : null; const workflowRuntime = workflowExecutor ? new WorkspaceWorkflowRuntime( this.workflows, @@ -742,6 +771,11 @@ export class WorkspaceRuntime snapshots: tabularSnapshots, }) : null; + const inferenceScopeResolver = new CanonicalProjectInferenceScopeResolver( + chatsRepository, + this.workflows, + tabularRepository, + ); const parser = new WorkspaceDocumentParser( this.documentRepository, this.blobs, @@ -754,6 +788,7 @@ export class WorkspaceRuntime new WorkspaceJobPump({ jobs: this.jobs, abortRegistry: this.abortRegistry, + inferenceScopeResolver: inferenceScopeResolver.resolve, concurrency: assistantRuntime || workflowRuntime || tabularCellHandler ? 2 : 1, handlers: { diff --git a/backend/src/lib/workspace/services/jobs.ts b/backend/src/lib/workspace/services/jobs.ts index 2b57956..a0ee21b 100644 --- a/backend/src/lib/workspace/services/jobs.ts +++ b/backend/src/lib/workspace/services/jobs.ts @@ -5,7 +5,13 @@ import { canReuseCompletedJob, projectWorkspaceJobForLogs, } from "../jobs/stateMachine"; -import type { WorkspaceJobEvent, WorkspaceJobType } from "../jobs/types"; +import { + PROJECT_INFERENCE_JOB_TYPES, + type ProjectInferenceScopeResolver, + type WorkspaceInferenceActivityScope, + type WorkspaceJobEvent, + type WorkspaceJobType, +} from "../jobs/types"; import { DuplicateWorkspaceJobError, FinishWorkspaceJobClaimInput, @@ -18,6 +24,7 @@ import { type WorkspaceJobResourceType, type WorkspaceJobStoredRecord, } from "../repositories/jobs"; +import { WorkspaceIdSchema } from "../workspacePersistencePrimitivesV1"; export type WorkspaceJobExecutionClaim = Readonly<{ leaseOwner: string; @@ -71,6 +78,7 @@ export interface WorkspaceJobsServiceOptions { now?: () => Date; createId?: () => string; abortRegistry?: WorkspaceJobAbortRegistry; + inferenceScopeResolver?: ProjectInferenceScopeResolver; } const UUID_PATTERN = @@ -177,30 +185,57 @@ function transitionCancelReason( } export class WorkspaceJobAbortRegistry { - private readonly controllers = new Map(); + private readonly entries = new Map< + string, + Map + >(); - register(jobId: string, controller: AbortController): void { - this.controllers.set(jobId, controller); + /** + * Read-only execution snapshot for transaction-local policy checks. The + * caller receives copied frozen scope values, never AbortControllers or the + * live registry map. + */ + activeInferenceScopes(): readonly WorkspaceInferenceActivityScope[] { + return [...this.entries.values()] + .flatMap((entries) => [...entries.values()]) + .filter( + (scope): scope is WorkspaceInferenceActivityScope => scope !== null, + ) + .map((scope) => Object.freeze({ ...scope })); } - unregister(jobId: string, controller?: AbortController): void { - if (controller) { - const current = this.controllers.get(jobId); - if (current !== controller) return; - } - this.controllers.delete(jobId); + register( + jobId: string, + controller: AbortController, + activityScope: WorkspaceInferenceActivityScope | null = null, + ): void { + const entries = + this.entries.get(jobId) ?? + new Map(); + entries.set(controller, activityScope); + this.entries.set(jobId, entries); + } + + unregister(jobId: string, controller: AbortController): void { + const entries = this.entries.get(jobId); + if (!entries) return; + entries.delete(controller); + if (entries.size === 0) this.entries.delete(jobId); } abort(jobId: string): boolean { - const controller = this.controllers.get(jobId); - if (!controller) return false; - controller.abort(); + const entries = this.entries.get(jobId); + if (!entries || entries.size === 0) return false; + for (const controller of entries.keys()) controller.abort(); return true; } abortAll(): void { - for (const controller of this.controllers.values()) controller.abort(); - this.controllers.clear(); + // Aborting is not execution completion. Keep every scope registered until + // its handler's controller-identity finally block independently unwinds. + for (const entries of this.entries.values()) { + for (const controller of entries.keys()) controller.abort(); + } } } @@ -775,6 +810,41 @@ export class WorkspaceJobRuntime { }; } + private inferenceActivityScope( + claimed: WorkspaceJobStoredRecord, + ): WorkspaceInferenceActivityScope | null { + const inferenceType = PROJECT_INFERENCE_JOB_TYPES.find( + (type) => type === claimed.type, + ); + if (!inferenceType) return null; + const unresolved = (): WorkspaceInferenceActivityScope => + Object.freeze({ + jobId: claimed.id, + type: inferenceType, + scope: "unresolved" as const, + projectId: null, + }); + try { + const resolved = this.options.inferenceScopeResolver?.(claimed); + if ( + resolved === undefined || + resolved === null || + resolved.jobId !== claimed.id || + resolved.type !== inferenceType || + !WorkspaceIdSchema.safeParse(resolved.jobId).success || + (resolved.scope === "project" + ? !WorkspaceIdSchema.safeParse(resolved.projectId).success + : (resolved.scope !== "global" && resolved.scope !== "unresolved") || + resolved.projectId !== null) + ) { + return unresolved(); + } + return Object.freeze({ ...resolved }); + } catch { + return unresolved(); + } + } + private async runClaimedJob( claimed: WorkspaceJobStoredRecord, externalSignal?: AbortSignal, @@ -804,7 +874,11 @@ export class WorkspaceJobRuntime { } else { externalSignal?.addEventListener("abort", onAbort, { once: true }); } - this.abortRegistry.register(claimed.id, controller); + // Scope resolution is deliberately synchronous and precedes registration; + // a resolver failure becomes an unresolved fail-closed entry rather than + // opening an untracked provider-execution window. + const activityScope = this.inferenceActivityScope(claimed); + this.abortRegistry.register(claimed.id, controller, activityScope); const stopHeartbeat = this.startHeartbeat(claim, controller); try { if (controller.signal.aborted) { diff --git a/backend/src/lib/workspace/services/projectInferenceScope.ts b/backend/src/lib/workspace/services/projectInferenceScope.ts new file mode 100644 index 0000000..169283a --- /dev/null +++ b/backend/src/lib/workspace/services/projectInferenceScope.ts @@ -0,0 +1,180 @@ +import { isDeepStrictEqual } from "node:util"; + +import type { ChatsRepository } from "../repositories/chats"; +import type { WorkspaceJobStoredRecord } from "../repositories/jobs"; +import type { TabularRepository } from "../repositories/tabular"; +import type { WorkflowsService } from "./workflows"; +import { + PROJECT_INFERENCE_JOB_TYPES, + type ProjectInferenceJobType, + type ProjectInferenceScopeResolver, + type WorkspaceInferenceActivityScope, +} from "../jobs/types"; +import { TabularCellJobPayloadSchema } from "../tabularGenerationContract"; +import { WorkspaceIdSchema } from "../workspacePersistencePrimitivesV1"; +import { workflowPayloadFromJob } from "./workflowRuntime"; + +function inferenceType( + value: WorkspaceJobStoredRecord["type"], +): ProjectInferenceJobType | null { + return PROJECT_INFERENCE_JOB_TYPES.find((type) => type === value) ?? null; +} + +function unresolved( + job: WorkspaceJobStoredRecord, + type: ProjectInferenceJobType, +): WorkspaceInferenceActivityScope { + return Object.freeze({ + jobId: job.id, + type, + scope: "unresolved" as const, + projectId: null, + }); +} + +function verifiedScope( + job: WorkspaceJobStoredRecord, + type: ProjectInferenceJobType, + projectId: string | null, +): WorkspaceInferenceActivityScope | null { + if (!WorkspaceIdSchema.safeParse(job.id).success) return null; + if (projectId === null) { + return Object.freeze({ + jobId: job.id, + type, + scope: "global" as const, + projectId: null, + }); + } + const parsedProjectId = WorkspaceIdSchema.safeParse(projectId); + if (!parsedProjectId.success) return null; + return Object.freeze({ + jobId: job.id, + type, + scope: "project" as const, + projectId: parsedProjectId.data, + }); +} + +/** + * Resolves and freezes Project ownership synchronously after a durable claim + * and before handler registration. Every source is an existing immutable or + * strictly validated execution contract; arbitrary payload JSON is never an + * authorization source. + */ +export class CanonicalProjectInferenceScopeResolver { + readonly resolve: ProjectInferenceScopeResolver; + + constructor( + private readonly assistant: Pick, + private readonly workflows: Pick< + WorkflowsService, + "getExecutionSnapshot" | "getRun" + >, + private readonly tabular: Pick, + ) { + this.resolve = this.resolveClaim.bind(this); + } + + private resolveClaim( + job: WorkspaceJobStoredRecord, + ): WorkspaceInferenceActivityScope | null { + const type = inferenceType(job.type); + if (type === null) return null; + const fallback = () => unresolved(job, type); + if (job.status !== "running") return fallback(); + try { + if (type === "assistant_generate") { + if (job.resourceType !== "chat") return fallback(); + const snapshot = this.assistant.generationSnapshot(job.id); + if ( + snapshot.jobId !== job.id || + snapshot.chatId !== job.resourceId || + snapshot.payload.chatId !== job.resourceId || + !isDeepStrictEqual(snapshot.payload, job.payload) + ) { + return fallback(); + } + return ( + verifiedScope(job, type, snapshot.payload.projectId) ?? fallback() + ); + } + + if (type === "workflow_run") { + if (job.resourceType !== "workflow_run") return fallback(); + const payload = workflowPayloadFromJob(job.payload); + if (!payload || payload.runId !== job.resourceId) return fallback(); + const snapshot = this.workflows.getExecutionSnapshot(payload.runId); + const detail = this.workflows.getRun(payload.runId); + if ( + snapshot.workflowRunId !== payload.runId || + snapshot.id !== payload.snapshotId || + snapshot.snapshotSha256 !== payload.snapshotSha256 || + snapshot.workflowId !== payload.workflowId || + snapshot.workflowId !== detail.run.workflowId || + snapshot.projectId !== detail.run.projectId || + detail.run.jobId !== job.id || + detail.run.retryOfRunId !== payload.retryOfRunId + ) { + return fallback(); + } + return verifiedScope(job, type, snapshot.projectId) ?? fallback(); + } + + if (job.resourceType !== "tabular_cell") return fallback(); + const payload = TabularCellJobPayloadSchema.safeParse(job.payload); + if ( + !payload.success || + payload.data.cellId !== job.resourceId || + payload.data.generationId !== job.id + ) { + return fallback(); + } + const fixed = payload.data; + const owner = this.tabular.database + .prepare( + `SELECT 1 AS present + FROM jobs persisted_job + JOIN tabular_cells cell + ON cell.id = persisted_job.resource_id + AND cell.job_id = persisted_job.id + JOIN tabular_reviews review ON review.id = cell.review_id + JOIN tabular_review_columns review_column + ON review_column.id = cell.column_id + AND review_column.review_id = review.id + JOIN tabular_review_documents review_document + ON review_document.review_id = review.id + AND review_document.document_id = cell.document_id + JOIN documents document + ON document.id = cell.document_id + AND document.deleted_at IS NULL + WHERE persisted_job.id = ? + AND persisted_job.type = 'tabular_cell' + AND persisted_job.status = 'running' + AND persisted_job.resource_type = 'tabular_cell' + AND persisted_job.resource_id = ? + AND cell.id = ? + AND cell.review_id = ? + AND cell.document_id = ? + AND cell.column_id = ? + AND review.project_id = ? + AND document.project_id = ? + LIMIT 1`, + ) + .get( + job.id, + job.resourceId, + fixed.cellId, + fixed.reviewId, + fixed.document.documentId, + fixed.column.columnId, + fixed.projectId, + fixed.projectId, + ); + if (owner?.present !== 1 && owner?.present !== 1n) return fallback(); + return verifiedScope(job, type, fixed.projectId) ?? fallback(); + } catch { + return fallback(); + } + } +} diff --git a/backend/src/lib/workspace/services/workflowRuntime.ts b/backend/src/lib/workspace/services/workflowRuntime.ts index 45d4678..0cdb34b 100644 --- a/backend/src/lib/workspace/services/workflowRuntime.ts +++ b/backend/src/lib/workspace/services/workflowRuntime.ts @@ -117,7 +117,7 @@ type WorkflowRunJobPayload = { retryOfRunId: string | null; }; -function workflowPayloadFromJob( +export function workflowPayloadFromJob( payload: unknown, ): WorkflowRunJobPayload | null { if ( diff --git a/backend/src/matter/inferencePolicy.ts b/backend/src/matter/inferencePolicy.ts new file mode 100644 index 0000000..2496560 --- /dev/null +++ b/backend/src/matter/inferencePolicy.ts @@ -0,0 +1,105 @@ +import { WorkspaceApiError } from "../lib/workspace/errors"; +import type { WorkspaceDatabaseAdapter } from "../lib/workspace/migrations"; +import type { + AssistantModelToolCall, + AssistantToolContext, + AssistantToolPort, +} from "../lib/workspace/services/assistantRuntime"; +import type { + WorkflowPreparedStepInput, + WorkflowStepExecutionResult, + WorkflowStepExecutor, +} from "../lib/workspace/services/workflowRuntime"; +import { WorkspaceIdSchema } from "../lib/workspace/workspacePersistencePrimitivesV1"; + +export const MATTER_INFERENCE_POLICY_MESSAGE = + "Matter inference is unavailable until a policy is configured."; + +/** + * Gate 1 inference boundary. Generic Projects and the global Assistant retain + * their existing Workspace behavior, while any Project that has explicitly + * acquired a Matter Profile fails closed until Gate 3 installs the policy + * resolver. The check runs synchronously at the final provider boundary so a + * deep link, stale UI, or queued job cannot bypass it. + */ +export class MatterInferencePolicyGate { + constructor(private readonly database: WorkspaceDatabaseAdapter) {} + + state( + projectId: string | null, + ): "workspace_compatibility" | "policy_gate_closed" { + if (projectId === null) return "workspace_compatibility"; + const parsed = WorkspaceIdSchema.safeParse(projectId); + if (!parsed.success) { + throw new WorkspaceApiError( + 412, + "PRECONDITION_FAILED", + MATTER_INFERENCE_POLICY_MESSAGE, + ); + } + const profile = this.database + .prepare("SELECT 1 AS present FROM matter_profiles WHERE project_id = ?") + .get(parsed.data) as { present?: unknown } | undefined; + return profile ? "policy_gate_closed" : "workspace_compatibility"; + } + + assertProjectModelUse(projectId: string | null): void { + if (this.state(projectId) === "policy_gate_closed") { + throw new WorkspaceApiError( + 412, + "PRECONDITION_FAILED", + MATTER_INFERENCE_POLICY_MESSAGE, + ); + } + } +} + +/** Keeps the policy check in front of every Assistant model round. */ +export class MatterPolicyAssistantToolPort implements AssistantToolPort { + constructor( + private readonly delegate: AssistantToolPort, + private readonly policy: Pick, + ) {} + + async assertModelUse(context: AssistantToolContext): Promise { + this.policy.assertProjectModelUse(context.projectId); + await this.delegate.assertModelUse?.(context); + } + + registeredTools(context: AssistantToolContext) { + return this.delegate.registeredTools(context); + } + + execute(input: { + context: AssistantToolContext; + call: AssistantModelToolCall; + signal: AbortSignal; + }) { + return this.delegate.execute(input); + } +} + +/** Blocks only model-producing Workflow steps; retrieval/output stay usable. */ +export class MatterPolicyWorkflowStepExecutor implements WorkflowStepExecutor { + constructor( + private readonly delegate: WorkflowStepExecutor, + private readonly policy: Pick, + ) {} + + prepareStep(input: Parameters>[0]): + | WorkflowPreparedStepInput + | Promise { + return this.delegate.prepareStep + ? this.delegate.prepareStep(input) + : { status: "ready", input: {} }; + } + + executeStep( + input: Parameters[0], + ): WorkflowStepExecutionResult | Promise { + if (input.step.kind === "prompt") { + this.policy.assertProjectModelUse(input.snapshot.projectId); + } + return this.delegate.executeStep(input); + } +} diff --git a/backend/src/matter/profile/contracts.ts b/backend/src/matter/profile/contracts.ts new file mode 100644 index 0000000..b3e6b94 --- /dev/null +++ b/backend/src/matter/profile/contracts.ts @@ -0,0 +1,520 @@ +import { z, type ZodError } from "zod"; + +import { + UnicodeCodePointStringSchemaV1, + WorkspaceIdSchema, +} from "../../lib/workspace/workspacePersistencePrimitivesV1"; + +export const WORKSPACE_TYPES = [ + "general_legal", + "transaction", + "dispute", + "investigation", + "compliance", + "research", +] as const; + +export const MATTER_PROFILE_STATES = [ + "absent", + "classification_required", + "ready", +] as const; + +export const WorkspaceTypeSchema = z.enum(WORKSPACE_TYPES); +export const MatterProfileStateSchema = z.enum(MATTER_PROFILE_STATES); + +/** v15/v16 profile timestamps are canonical millisecond UTC instants. */ +export const MatterUtcTimestampSchema = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) + .refine((value) => { + const parsed = new Date(value); + return !Number.isNaN(parsed.valueOf()) && parsed.toISOString() === value; + }, "timestamp must be a canonical millisecond UTC instant"); + +function persistedMatterText(max: number) { + return UnicodeCodePointStringSchemaV1({ + min: 1, + max, + trimForMin: true, + }); +} + +function matterTextInput(max: number) { + return persistedMatterText(max).transform((value) => value.trim()); +} + +const PersistedClientNameSchema = persistedMatterText(500); +const PersistedJurisdictionSchema = persistedMatterText(240); +const PersistedRepresentedRoleSchema = persistedMatterText(240); +const PersistedObjectiveSchema = persistedMatterText(16_384); + +const ClientNameInputSchema = matterTextInput(500); +const JurisdictionInputSchema = matterTextInput(240); +const RepresentedRoleInputSchema = matterTextInput(240); +const ObjectiveInputSchema = matterTextInput(16_384); + +/** + * Public Matter Profile contract. v15 litigation-specific columns remain + * private compatibility storage and are deliberately absent here. + */ +export const MatterProfileSchema = z + .object({ + projectId: WorkspaceIdSchema, + workspaceType: WorkspaceTypeSchema.nullable(), + clientName: PersistedClientNameSchema.nullable(), + jurisdiction: PersistedJurisdictionSchema.nullable(), + representedRole: PersistedRepresentedRoleSchema.nullable(), + objective: PersistedObjectiveSchema.nullable(), + createdAt: MatterUtcTimestampSchema, + updatedAt: MatterUtcTimestampSchema, + }) + .strict() + .superRefine((value, context) => { + if (value.updatedAt < value.createdAt) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["updatedAt"], + message: "updatedAt must not precede createdAt", + }); + } + }); + +const PersistedProjectNameSchema = persistedMatterText(240); +const PersistedProjectDescriptionSchema = UnicodeCodePointStringSchemaV1({ + max: 2_000, +}); +const PersistedProjectCmNumberSchema = UnicodeCodePointStringSchemaV1({ + max: 160, +}); +const PersistedProjectPracticeSchema = UnicodeCodePointStringSchemaV1({ + max: 160, +}); + +/** Existing Projects legitimately permit empty nullable metadata strings. */ +export const MatterProjectProjectionSchema = z + .object({ + id: WorkspaceIdSchema, + name: PersistedProjectNameSchema, + description: PersistedProjectDescriptionSchema.nullable(), + cmNumber: PersistedProjectCmNumberSchema.nullable(), + practice: PersistedProjectPracticeSchema.nullable(), + status: z.enum(["active", "archived", "deleted"]), + defaultModelProfileId: WorkspaceIdSchema.nullable(), + createdAt: z.string().datetime({ offset: true }), + updatedAt: z.string().datetime({ offset: true }), + archivedAt: z.string().datetime({ offset: true }).nullable(), + documentCount: z.number().int().nonnegative(), + chatCount: z.number().int().nonnegative(), + tabularReviewCount: z.number().int().nonnegative(), + workflowCount: z.number().int().nonnegative(), + }) + .strict(); + +export const MatterCapabilitiesSchema = z + .object({ + matterProfile: z.enum(["create", "classify", "edit", "unavailable"]), + inference: z.enum([ + "workspace_compatibility", + "policy_gate_closed", + "unavailable", + ]), + review: z.literal("unavailable"), + drafts: z.literal("document_scoped"), + }) + .strict(); + +export type WorkspaceType = z.infer; +export type MatterProfileState = z.infer; +export type MatterProfile = z.infer; +export type MatterCapabilities = z.infer; + +export function matterProfilePresentation( + projectStatus: "active" | "archived" | "deleted", + profile: MatterProfile | null, +): { + profileState: MatterProfileState; + capabilities: MatterCapabilities; +} { + const profileState: MatterProfileState = + profile === null + ? "absent" + : profile.workspaceType === null + ? "classification_required" + : "ready"; + if (projectStatus !== "active") { + return { + profileState, + capabilities: { + matterProfile: "unavailable", + inference: "unavailable", + review: "unavailable", + drafts: "document_scoped", + }, + }; + } + if (profile === null) { + return { + profileState, + capabilities: { + matterProfile: "create", + inference: "workspace_compatibility", + review: "unavailable", + drafts: "document_scoped", + }, + }; + } + return { + profileState, + capabilities: { + matterProfile: profile.workspaceType === null ? "classify" : "edit", + inference: "policy_gate_closed", + review: "unavailable", + drafts: "document_scoped", + }, + }; +} + +export const MatterViewSchema = z + .object({ + project: MatterProjectProjectionSchema, + profile: MatterProfileSchema.nullable(), + profileState: MatterProfileStateSchema, + capabilities: MatterCapabilitiesSchema, + }) + .strict() + .superRefine((value, context) => { + if ( + value.profile !== null && + value.profile.projectId !== value.project.id + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["profile", "projectId"], + message: "Matter Profile must belong to the projected Project", + }); + } + const expected = matterProfilePresentation( + value.project.status, + value.profile, + ); + if (value.profileState !== expected.profileState) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["profileState"], + message: "Matter Profile state is inconsistent", + }); + } + if ( + JSON.stringify(value.capabilities) !== + JSON.stringify(expected.capabilities) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["capabilities"], + message: "Matter capabilities are inconsistent", + }); + } + }); + +const CreateProfileShape = { + workspaceType: WorkspaceTypeSchema, + clientName: ClientNameInputSchema.nullable().optional(), + jurisdiction: JurisdictionInputSchema.nullable().optional(), + representedRole: RepresentedRoleInputSchema.nullable().optional(), + objective: ObjectiveInputSchema.nullable().optional(), +} satisfies z.ZodRawShape; + +export const CreateMatterProfileRequestSchema = z + .object(CreateProfileShape) + .strict(); + +export const CreateMatterRequestSchema = z + .object({ + name: matterTextInput(240), + description: matterTextInput(2_000).nullable().optional(), + cmNumber: matterTextInput(160).nullable().optional(), + practice: matterTextInput(160).nullable().optional(), + ...CreateProfileShape, + }) + .strict(); + +export const UpdateMatterProfileRequestSchema = z + .object({ + workspaceType: WorkspaceTypeSchema.optional(), + clientName: ClientNameInputSchema.nullable().optional(), + jurisdiction: JurisdictionInputSchema.nullable().optional(), + representedRole: RepresentedRoleInputSchema.nullable().optional(), + objective: ObjectiveInputSchema.nullable().optional(), + }) + .strict() + .refine((value) => Object.keys(value).length > 0, { + message: "at least one Matter Profile update is required", + }); + +export const MatterListRequestSchema = z + .object({ + status: z.enum(["active", "archived"]).optional(), + cursor: z.string().min(1).max(512).nullable().optional(), + limit: z.number().int().min(1).max(100).optional(), + }) + .strict(); + +export const MatterViewPageSchema = z + .object({ + items: z.array(MatterViewSchema).max(100), + nextCursor: z.string().min(1).max(512).nullable(), + }) + .strict(); + +export const MatterProfileWireSchema = z + .object({ + project_id: WorkspaceIdSchema, + workspace_type: WorkspaceTypeSchema.nullable(), + client_name: PersistedClientNameSchema.nullable(), + jurisdiction: PersistedJurisdictionSchema.nullable(), + represented_role: PersistedRepresentedRoleSchema.nullable(), + objective: PersistedObjectiveSchema.nullable(), + created_at: MatterUtcTimestampSchema, + updated_at: MatterUtcTimestampSchema, + }) + .strict(); + +export const MatterProjectWireSchema = z + .object({ + id: WorkspaceIdSchema, + name: PersistedProjectNameSchema, + description: PersistedProjectDescriptionSchema.nullable(), + cm_number: PersistedProjectCmNumberSchema.nullable(), + practice: PersistedProjectPracticeSchema.nullable(), + status: z.enum(["active", "archived", "deleted"]), + default_model_profile_id: WorkspaceIdSchema.nullable(), + created_at: z.string().datetime({ offset: true }), + updated_at: z.string().datetime({ offset: true }), + archived_at: z.string().datetime({ offset: true }).nullable(), + document_count: z.number().int().nonnegative(), + chat_count: z.number().int().nonnegative(), + tabular_review_count: z.number().int().nonnegative(), + workflow_count: z.number().int().nonnegative(), + }) + .strict(); + +export const MatterCapabilitiesWireSchema = z + .object({ + matter_profile: z.enum(["create", "classify", "edit", "unavailable"]), + inference: z.enum([ + "workspace_compatibility", + "policy_gate_closed", + "unavailable", + ]), + review: z.literal("unavailable"), + drafts: z.literal("document_scoped"), + }) + .strict(); + +export const MatterViewWireSchema = z + .object({ + project: MatterProjectWireSchema, + matter_profile: MatterProfileWireSchema.nullable(), + profile_state: MatterProfileStateSchema, + capabilities: MatterCapabilitiesWireSchema, + }) + .strict(); + +export const MatterViewPageWireSchema = z + .object({ + items: z.array(MatterViewWireSchema).max(100), + next_cursor: z.string().min(1).max(512).nullable(), + }) + .strict(); + +export const MatterListWireQuerySchema = z + .object({ + status: z.enum(["active", "archived"]).optional(), + cursor: z.string().min(1).max(512).optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + }) + .strict(); + +const CreateProfileWireShape = { + workspace_type: WorkspaceTypeSchema, + client_name: ClientNameInputSchema.nullable().optional(), + jurisdiction: JurisdictionInputSchema.nullable().optional(), + represented_role: RepresentedRoleInputSchema.nullable().optional(), + objective: ObjectiveInputSchema.nullable().optional(), +} satisfies z.ZodRawShape; + +export const CreateMatterProfileWireRequestSchema = z + .object(CreateProfileWireShape) + .strict(); + +export const CreateMatterWireRequestSchema = z + .object({ + name: matterTextInput(240), + description: matterTextInput(2_000).nullable().optional(), + cm_number: matterTextInput(160).nullable().optional(), + practice: matterTextInput(160).nullable().optional(), + ...CreateProfileWireShape, + }) + .strict(); + +export const UpdateMatterProfileWireRequestSchema = z + .object({ + workspace_type: WorkspaceTypeSchema.optional(), + client_name: ClientNameInputSchema.nullable().optional(), + jurisdiction: JurisdictionInputSchema.nullable().optional(), + represented_role: RepresentedRoleInputSchema.nullable().optional(), + objective: ObjectiveInputSchema.nullable().optional(), + }) + .strict() + .refine((value) => Object.keys(value).length > 0, { + message: "at least one Matter Profile update is required", + }); + +export type MatterView = z.infer; +export type MatterViewPage = z.infer; +export type CreateMatterRequest = z.infer; +export type CreateMatterProfileRequest = z.infer< + typeof CreateMatterProfileRequestSchema +>; +export type UpdateMatterProfileRequest = z.infer< + typeof UpdateMatterProfileRequestSchema +>; +export type MatterListRequest = z.infer; + +const PUBLIC_VALIDATION_PATHS = new Set([ + "request", + "projectId", + "name", + "description", + "cmNumber", + "practice", + "workspaceType", + "clientName", + "jurisdiction", + "representedRole", + "objective", + "status", + "cursor", + "limit", + "project_id", + "cm_number", + "workspace_type", + "client_name", + "represented_role", +]); + +/** Zod enum messages can echo rejected source values; public details never do. */ +export function safeMatterValidationDetails(error: ZodError) { + return error.issues.slice(0, 100).map((issue) => { + const path = issue.path + .map((part) => String(part)) + .filter((part) => PUBLIC_VALIDATION_PATHS.has(part)) + .join("."); + return { + path: path || "request", + message: "Invalid value.", + }; + }); +} + +export function parseCreateMatterWire(value: unknown): CreateMatterRequest { + const input = CreateMatterWireRequestSchema.parse(value); + return CreateMatterRequestSchema.parse({ + name: input.name, + description: input.description, + cmNumber: input.cm_number, + practice: input.practice, + workspaceType: input.workspace_type, + clientName: input.client_name, + jurisdiction: input.jurisdiction, + representedRole: input.represented_role, + objective: input.objective, + }); +} + +export function parseCreateMatterProfileWire( + value: unknown, +): CreateMatterProfileRequest { + const input = CreateMatterProfileWireRequestSchema.parse(value); + return CreateMatterProfileRequestSchema.parse({ + workspaceType: input.workspace_type, + clientName: input.client_name, + jurisdiction: input.jurisdiction, + representedRole: input.represented_role, + objective: input.objective, + }); +} + +export function parseUpdateMatterProfileWire( + value: unknown, +): UpdateMatterProfileRequest { + const input = UpdateMatterProfileWireRequestSchema.parse(value); + return UpdateMatterProfileRequestSchema.parse({ + ...(input.workspace_type === undefined + ? {} + : { workspaceType: input.workspace_type }), + ...(input.client_name === undefined + ? {} + : { clientName: input.client_name }), + ...(input.jurisdiction === undefined + ? {} + : { jurisdiction: input.jurisdiction }), + ...(input.represented_role === undefined + ? {} + : { representedRole: input.represented_role }), + ...(input.objective === undefined ? {} : { objective: input.objective }), + }); +} + +export function toMatterProfileWire(profile: MatterProfile) { + return MatterProfileWireSchema.parse({ + project_id: profile.projectId, + workspace_type: profile.workspaceType, + client_name: profile.clientName, + jurisdiction: profile.jurisdiction, + represented_role: profile.representedRole, + objective: profile.objective, + created_at: profile.createdAt, + updated_at: profile.updatedAt, + }); +} + +export function toMatterViewWire(value: MatterView) { + const view = MatterViewSchema.parse(value); + return MatterViewWireSchema.parse({ + project: { + id: view.project.id, + name: view.project.name, + description: view.project.description, + cm_number: view.project.cmNumber, + practice: view.project.practice, + status: view.project.status, + default_model_profile_id: view.project.defaultModelProfileId, + created_at: view.project.createdAt, + updated_at: view.project.updatedAt, + archived_at: view.project.archivedAt, + document_count: view.project.documentCount, + chat_count: view.project.chatCount, + tabular_review_count: view.project.tabularReviewCount, + workflow_count: view.project.workflowCount, + }, + matter_profile: + view.profile === null ? null : toMatterProfileWire(view.profile), + profile_state: view.profileState, + capabilities: { + matter_profile: view.capabilities.matterProfile, + inference: view.capabilities.inference, + review: view.capabilities.review, + drafts: view.capabilities.drafts, + }, + }); +} + +export function toMatterViewPageWire(value: MatterViewPage) { + const page = MatterViewPageSchema.parse(value); + return MatterViewPageWireSchema.parse({ + items: page.items.map(toMatterViewWire), + next_cursor: page.nextCursor, + }); +} diff --git a/backend/src/matter/profile/index.ts b/backend/src/matter/profile/index.ts new file mode 100644 index 0000000..003c1c4 --- /dev/null +++ b/backend/src/matter/profile/index.ts @@ -0,0 +1,68 @@ +import type { WorkspaceDatabaseAdapter } from "../../lib/workspace/migrations"; +import type { WorkspaceInferenceActivityScope } from "../../lib/workspace/jobs/types"; +import { ProjectsRepository } from "../../lib/workspace/repositories/projects"; +import { + createMatterProfileV1Router, + type MatterProfileV1Port, + type MatterProfileV1RouterOptions, +} from "./router"; +import { MatterOverviewRepository } from "./overviewRepository"; +import { MatterProfileRepository } from "./repository"; +import { + createProjectInferenceActivityPort, + MatterProfileService, + type MatterProfileServiceOptions, +} from "./service"; + +export { WORKSPACE_TYPES } from "./contracts"; +export type { WorkspaceType } from "./contracts"; +export type { + MatterProfileV1Port, + MatterProfileV1RouterOptions, +} from "./router"; + +/** + * Bounded Matter Profile composition seam. The caller must inject the same + * canonical ProjectsRepository instance used by the Workspace Project service; + * this module never opens a database or reimplements Project persistence. + */ +export function createMatterProfileModule( + database: WorkspaceDatabaseAdapter, + projects: ProjectsRepository, + options: MatterProfileServiceOptions & { + activeInferenceScopes: () => readonly WorkspaceInferenceActivityScope[]; + }, +) { + const profiles = new MatterProfileRepository(database); + const overview = new MatterOverviewRepository(database); + const inferenceActivity = createProjectInferenceActivityPort( + projects, + options.activeInferenceScopes, + ); + const service = new MatterProfileService( + database, + projects, + profiles, + overview, + inferenceActivity, + options, + ); + const api: MatterProfileV1Port = Object.freeze({ + listMatters: service.listMatters.bind(service), + createMatter: service.createMatter.bind(service), + getMatter: service.getMatter.bind(service), + getProjectMatterProfile: service.getProjectMatterProfile.bind(service), + createProjectMatterProfile: + service.createProjectMatterProfile.bind(service), + updateProjectMatterProfile: + service.updateProjectMatterProfile.bind(service), + }); + return Object.freeze({ + api, + health: () => service.health(), + createRouter: (routerOptions: MatterProfileV1RouterOptions = {}) => + createMatterProfileV1Router(api, routerOptions), + }); +} + +export type MatterProfileModule = ReturnType; diff --git a/backend/src/matter/profile/overviewRepository.ts b/backend/src/matter/profile/overviewRepository.ts new file mode 100644 index 0000000..2956a47 --- /dev/null +++ b/backend/src/matter/profile/overviewRepository.ts @@ -0,0 +1,267 @@ +import { z } from "zod"; + +import { WorkspaceApiError } from "../../lib/workspace/errors"; +import type { WorkspaceDatabaseAdapter } from "../../lib/workspace/migrations"; +import { WorkspaceIdSchema } from "../../lib/workspace/workspacePersistencePrimitivesV1"; +import { + MatterListRequestSchema, + MatterProfileSchema, + MatterProjectProjectionSchema, + MatterViewPageSchema, + MatterViewSchema, + matterProfilePresentation, + type MatterListRequest, + type MatterProfile, + type MatterView, + type MatterViewPage, +} from "./contracts"; + +type Row = Record; + +const PROJECT_MATTER_COLUMNS = ` + p.id AS project_id, + p.name AS project_name, + p.description AS project_description, + p.cm_number AS project_cm_number, + p.practice AS project_practice, + p.status AS project_status, + p.default_model_profile_id AS project_default_model_profile_id, + p.created_at AS project_created_at, + p.updated_at AS project_updated_at, + p.archived_at AS project_archived_at, + coalesce(dc.total, 0) AS project_document_count, + coalesce(cc.total, 0) AS project_chat_count, + coalesce(trc.total, 0) AS project_tabular_review_count, + coalesce(wc.total, 0) AS project_workflow_count, + mp.project_id AS profile_project_id, + mp.workspace_type AS profile_workspace_type, + mp.client_name AS profile_client_name, + mp.jurisdiction AS profile_jurisdiction, + mp.represented_role AS profile_represented_role, + mp.objective AS profile_objective, + mp.created_at AS profile_created_at, + mp.updated_at AS profile_updated_at +`; + +function projectionQuery(selectedProjectsSql: string): string { + return ` + WITH selected_projects AS ( + ${selectedProjectsSql} + ), + document_counts AS ( + SELECT d.project_id, count(*) AS total + FROM documents d + JOIN selected_projects selected ON selected.id = d.project_id + WHERE d.deleted_at IS NULL + GROUP BY d.project_id + ), + chat_counts AS ( + SELECT c.project_id, count(*) AS total + FROM chats c + JOIN selected_projects selected ON selected.id = c.project_id + GROUP BY c.project_id + ), + tabular_review_counts AS ( + SELECT review.project_id, count(*) AS total + FROM tabular_reviews review + JOIN selected_projects selected ON selected.id = review.project_id + GROUP BY review.project_id + ), + workflow_counts AS ( + SELECT workflow.project_id, count(*) AS total + FROM workflows workflow + JOIN selected_projects selected ON selected.id = workflow.project_id + GROUP BY workflow.project_id + ) + SELECT ${PROJECT_MATTER_COLUMNS} + FROM selected_projects p + LEFT JOIN document_counts dc ON dc.project_id = p.id + LEFT JOIN chat_counts cc ON cc.project_id = p.id + LEFT JOIN tabular_review_counts trc ON trc.project_id = p.id + LEFT JOIN workflow_counts wc ON wc.project_id = p.id + LEFT JOIN matter_profiles mp ON mp.project_id = p.id + ORDER BY p.updated_at DESC, p.id DESC + `; +} + +const CursorSchema = z + .object({ + updatedAt: z.string().datetime({ offset: true }), + id: WorkspaceIdSchema, + }) + .strict(); + +function validationError(message: string): never { + throw new WorkspaceApiError(400, "VALIDATION_ERROR", message); +} + +function internal(message: string): never { + throw new WorkspaceApiError(500, "INTERNAL_ERROR", message); +} + +function mapMatterProfile(row: Row): MatterProfile | null { + if (row.profile_project_id == null) return null; + try { + return MatterProfileSchema.parse({ + projectId: row.profile_project_id, + workspaceType: row.profile_workspace_type, + clientName: row.profile_client_name, + jurisdiction: row.profile_jurisdiction, + representedRole: row.profile_represented_role, + objective: row.profile_objective, + createdAt: row.profile_created_at, + updatedAt: row.profile_updated_at, + }); + } catch { + internal("Persisted Matter Profile is invalid."); + } +} + +function mapMatterView(row: Row): MatterView { + try { + const profile = mapMatterProfile(row); + return MatterViewSchema.parse({ + project: { + id: row.project_id, + name: row.project_name, + description: row.project_description, + cmNumber: row.project_cm_number, + practice: row.project_practice, + status: row.project_status, + defaultModelProfileId: row.project_default_model_profile_id, + createdAt: row.project_created_at, + updatedAt: row.project_updated_at, + archivedAt: row.project_archived_at, + documentCount: Number(row.project_document_count), + chatCount: Number(row.project_chat_count), + tabularReviewCount: Number(row.project_tabular_review_count), + workflowCount: Number(row.project_workflow_count), + }, + profile, + ...matterProfilePresentation( + MatterProjectProjectionSchema.shape.status.parse(row.project_status), + profile, + ), + }); + } catch (error) { + if (error instanceof WorkspaceApiError) throw error; + internal("Persisted Matter projection is invalid."); + } +} + +function encodeCursor(view: MatterView): string { + return Buffer.from( + JSON.stringify({ + updatedAt: view.project.updatedAt, + id: view.project.id, + }), + "utf8", + ).toString("base64url"); +} + +function decodeCursor(value: string | null | undefined) { + if (value == null) return null; + if (!/^[A-Za-z0-9_-]{1,512}$/.test(value)) { + validationError("Matter pagination cursor is invalid."); + } + try { + const decoded = Buffer.from(value, "base64url").toString("utf8"); + if (Buffer.byteLength(decoded, "utf8") > 1_024) { + validationError("Matter pagination cursor is invalid."); + } + return CursorSchema.parse(JSON.parse(decoded) as unknown); + } catch (error) { + if (error instanceof WorkspaceApiError) throw error; + validationError("Matter pagination cursor is invalid."); + } +} + +export interface MatterOverviewReadPort { + readonly database: WorkspaceDatabaseAdapter; + list(input?: MatterListRequest): MatterViewPage; + get(projectId: string): MatterView | null; + require(projectId: string): MatterView; +} + +/** Read-only owner for Matter list/detail projection and aggregate counts. */ +export class MatterOverviewRepository implements MatterOverviewReadPort { + constructor(readonly database: WorkspaceDatabaseAdapter) {} + + private safe(operation: () => T): T { + try { + return operation(); + } catch (error) { + if (error instanceof WorkspaceApiError) throw error; + throw new WorkspaceApiError( + 500, + "INTERNAL_ERROR", + "Matter overview data operation failed.", + ); + } + } + + list(input: MatterListRequest = {}): MatterViewPage { + return this.safe(() => { + const request = MatterListRequestSchema.parse(input); + const cursor = decodeCursor(request.cursor); + const limit = request.limit ?? 50; + const rows = this.database + .prepare( + projectionQuery(` + SELECT p.* + FROM projects p + WHERE p.status = ? + ${ + cursor + ? "AND (p.updated_at < ? OR (p.updated_at = ? AND p.id < ?))" + : "" + } + ORDER BY p.updated_at DESC, p.id DESC + LIMIT ? + `), + ) + .all( + request.status ?? "active", + ...(cursor ? [cursor.updatedAt, cursor.updatedAt, cursor.id] : []), + limit + 1, + ); + const items = rows.slice(0, limit).map(mapMatterView); + const last = items.at(-1); + return MatterViewPageSchema.parse({ + items, + nextCursor: + rows.length > limit && last !== undefined ? encodeCursor(last) : null, + }); + }); + } + + get(projectId: string): MatterView | null { + return this.safe(() => { + const parsedProjectId = WorkspaceIdSchema.safeParse(projectId); + if (!parsedProjectId.success) { + validationError("Project id is invalid."); + } + const row = this.database + .prepare( + projectionQuery(` + SELECT p.* + FROM projects p + WHERE p.id = ? + LIMIT 1 + `), + ) + .get(parsedProjectId.data); + return row ? mapMatterView(row) : null; + }); + } + + require(projectId: string): MatterView { + return this.safe(() => { + const view = this.get(projectId); + if (!view) { + throw new WorkspaceApiError(404, "NOT_FOUND", "Project not found."); + } + return view; + }); + } +} diff --git a/backend/src/matter/profile/repository.ts b/backend/src/matter/profile/repository.ts new file mode 100644 index 0000000..d53807e --- /dev/null +++ b/backend/src/matter/profile/repository.ts @@ -0,0 +1,192 @@ +import { WorkspaceApiError } from "../../lib/workspace/errors"; +import type { WorkspaceDatabaseAdapter } from "../../lib/workspace/migrations"; +import { WorkspaceIdSchema } from "../../lib/workspace/workspacePersistencePrimitivesV1"; +import { MatterProfileSchema, type MatterProfile } from "./contracts"; + +type Row = Record; + +const V15_NON_SEMANTIC_MATTER_TYPE_SENTINEL = "general"; + +function internal(message: string): never { + throw new WorkspaceApiError(500, "INTERNAL_ERROR", message); +} + +function mapProfile(row: Row): MatterProfile { + try { + return MatterProfileSchema.parse({ + projectId: row.project_id, + workspaceType: row.workspace_type, + clientName: row.client_name, + jurisdiction: row.jurisdiction, + representedRole: row.represented_role, + objective: row.objective, + createdAt: row.created_at, + updatedAt: row.updated_at, + }); + } catch { + internal("Persisted Matter Profile is invalid."); + } +} + +export interface MatterProfilePersistencePort { + readonly database: WorkspaceDatabaseAdapter; + readiness(): { + status: "ready"; + schemaVersion: 16; + inferencePolicy: "gate_closed"; + }; + get(projectId: string): MatterProfile | null; + require(projectId: string): MatterProfile; + insert(profile: MatterProfile): MatterProfile; + update(profile: MatterProfile): MatterProfile; +} + +/** Profile-only persistence owner. Transaction coordination lives in service. */ +export class MatterProfileRepository implements MatterProfilePersistencePort { + constructor(readonly database: WorkspaceDatabaseAdapter) {} + + private safe(operation: () => T): T { + try { + return operation(); + } catch (error) { + if (error instanceof WorkspaceApiError) throw error; + throw new WorkspaceApiError( + 500, + "INTERNAL_ERROR", + "Matter Profile data operation failed.", + ); + } + } + + readiness(): { + status: "ready"; + schemaVersion: 16; + inferencePolicy: "gate_closed"; + } { + return this.safe(() => { + const migration = this.database + .prepare( + `SELECT name + FROM workspace_schema_migrations + WHERE version = 16`, + ) + .get() as { name?: unknown } | undefined; + if (migration?.name !== "matter_profile_classification") { + internal("Matter Profile schema is unavailable."); + } + this.database + .prepare( + "SELECT workspace_type, jurisdiction FROM matter_profiles LIMIT 1", + ) + .get(); + this.database + .prepare("SELECT project_id FROM matter_policies LIMIT 1") + .get(); + this.database + .prepare( + "SELECT project_id FROM matter_policy_execution_locations LIMIT 1", + ) + .get(); + return { + status: "ready" as const, + schemaVersion: 16 as const, + inferencePolicy: "gate_closed" as const, + }; + }); + } + + get(projectIdValue: string): MatterProfile | null { + return this.safe(() => { + const projectId = WorkspaceIdSchema.safeParse(projectIdValue); + if (!projectId.success) { + throw new WorkspaceApiError( + 400, + "VALIDATION_ERROR", + "Project id is invalid.", + ); + } + const row = this.database + .prepare( + `SELECT project_id, workspace_type, client_name, jurisdiction, + represented_role, objective, created_at, updated_at + FROM matter_profiles + WHERE project_id = ?`, + ) + .get(projectId.data); + return row ? mapProfile(row) : null; + }); + } + + require(projectId: string): MatterProfile { + return this.safe(() => { + const profile = this.get(projectId); + if (!profile) { + throw new WorkspaceApiError( + 404, + "NOT_FOUND", + "Matter Profile not found.", + ); + } + return profile; + }); + } + + insert(value: MatterProfile): MatterProfile { + return this.safe(() => { + const profile = MatterProfileSchema.parse(value); + if (profile.workspaceType === null) { + throw new WorkspaceApiError( + 400, + "VALIDATION_ERROR", + "Matter workspace classification is required.", + ); + } + this.database + .prepare( + `INSERT INTO matter_profiles ( + project_id, matter_type, workspace_type, client_name, jurisdiction, + represented_role, objective, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + profile.projectId, + V15_NON_SEMANTIC_MATTER_TYPE_SENTINEL, + profile.workspaceType, + profile.clientName, + profile.jurisdiction, + profile.representedRole, + profile.objective, + profile.createdAt, + profile.updatedAt, + ); + return this.require(profile.projectId); + }); + } + + update(value: MatterProfile): MatterProfile { + return this.safe(() => { + const profile = MatterProfileSchema.parse(value); + this.database + .prepare( + `UPDATE matter_profiles + SET workspace_type = ?, + client_name = ?, + jurisdiction = ?, + represented_role = ?, + objective = ?, + updated_at = ? + WHERE project_id = ?`, + ) + .run( + profile.workspaceType, + profile.clientName, + profile.jurisdiction, + profile.representedRole, + profile.objective, + profile.updatedAt, + profile.projectId, + ); + return this.require(profile.projectId); + }); + } +} diff --git a/backend/src/matter/profile/router.ts b/backend/src/matter/profile/router.ts new file mode 100644 index 0000000..90377d6 --- /dev/null +++ b/backend/src/matter/profile/router.ts @@ -0,0 +1,274 @@ +import { + Router, + type NextFunction, + type Request, + type Response, +} from "express"; +import { ZodError } from "zod"; + +import { WorkspaceApiError } from "../../lib/workspace/errors"; +import { WorkspaceIdSchema } from "../../lib/workspace/workspacePersistencePrimitivesV1"; +import { + MatterListWireQuerySchema, + parseCreateMatterProfileWire, + parseCreateMatterWire, + parseUpdateMatterProfileWire, + safeMatterValidationDetails, + toMatterViewPageWire, + toMatterViewWire, +} from "./contracts"; +import type { MatterProfileServiceContext } from "./service"; + +type Awaitable = T | Promise; + +/** Narrow HTTP seam. MatterProfileService satisfies this interface directly. */ +export interface MatterProfileV1Port { + listMatters( + context: MatterProfileServiceContext, + input: unknown, + ): Awaitable; + createMatter( + context: MatterProfileServiceContext, + input: unknown, + ): Awaitable; + getMatter( + context: MatterProfileServiceContext, + projectId: string, + ): Awaitable; + getProjectMatterProfile( + context: MatterProfileServiceContext, + projectId: string, + ): Awaitable; + createProjectMatterProfile( + context: MatterProfileServiceContext, + projectId: string, + input: unknown, + ): Awaitable; + updateProjectMatterProfile( + context: MatterProfileServiceContext, + projectId: string, + input: unknown, + ): Awaitable; +} + +export type MatterProfileV1RouterOptions = { + principal?: (request: Request) => string | undefined; +}; + +type AsyncHandler = (request: Request, response: Response) => Promise; + +function asyncRoute(handler: AsyncHandler) { + return (request: Request, response: Response, next: NextFunction) => { + void handler(request, response).catch(next); + }; +} + +function contextFor( + request: Request, + options: MatterProfileV1RouterOptions, +): MatterProfileServiceContext { + const response = request.res as Response | undefined; + const candidate = + options.principal?.(request) ?? + response?.locals.userId ?? + (request as Request & { userId?: unknown }).userId; + if ( + typeof candidate !== "string" || + !WorkspaceIdSchema.safeParse(candidate).success + ) { + throw new WorkspaceApiError( + 401, + "UNAUTHORIZED", + "Authentication is required.", + ); + } + return { principalId: candidate }; +} + +function projectId(request: Request): string { + return WorkspaceIdSchema.parse(request.params.projectId); +} + +function safeView(payload: unknown) { + try { + return toMatterViewWire(payload as Parameters[0]); + } catch { + throw new WorkspaceApiError( + 500, + "INTERNAL_ERROR", + "Matter response could not be serialized safely.", + ); + } +} + +function safePage(payload: unknown) { + try { + return toMatterViewPageWire( + payload as Parameters[0], + ); + } catch { + throw new WorkspaceApiError( + 500, + "INTERNAL_ERROR", + "Matter list response could not be serialized safely.", + ); + } +} + +function errorPayload(error: unknown) { + if (error instanceof ZodError) { + return { + status: 400, + body: { + detail: "Invalid request.", + code: "VALIDATION_ERROR", + error: { + code: "VALIDATION_ERROR", + message: "Invalid request.", + retryable: false, + details: safeMatterValidationDetails(error), + }, + }, + }; + } + if (error instanceof WorkspaceApiError) { + return { + status: error.status, + body: { + detail: error.message, + code: error.code, + error: { ...error.toResponse().error, retryable: false }, + }, + }; + } + return { + status: 500, + body: { + detail: "Internal server error.", + code: "INTERNAL_ERROR", + error: { + code: "INTERNAL_ERROR", + message: "Internal server error.", + retryable: false, + }, + }, + }; +} + +/** + * Mount at `/api/v1`. The legacy `/api/v1/projects` router remains unchanged; + * this additive router owns only Matters and the explicit Profile subresource. + */ +export function createMatterProfileV1Router( + port: MatterProfileV1Port, + options: MatterProfileV1RouterOptions = {}, +): Router { + const router = Router(); + + router.get( + "/matters", + asyncRoute(async (request, response) => { + const query = MatterListWireQuerySchema.parse(request.query); + response.json( + safePage( + await port.listMatters(contextFor(request, options), { + status: query.status, + cursor: query.cursor, + limit: query.limit, + }), + ), + ); + }), + ); + + router.post( + "/matters", + asyncRoute(async (request, response) => { + response + .status(201) + .json( + safeView( + await port.createMatter( + contextFor(request, options), + parseCreateMatterWire(request.body), + ), + ), + ); + }), + ); + + router.get( + "/matters/:projectId", + asyncRoute(async (request, response) => { + response.json( + safeView( + await port.getMatter( + contextFor(request, options), + projectId(request), + ), + ), + ); + }), + ); + + router.get( + "/projects/:projectId/matter-profile", + asyncRoute(async (request, response) => { + response.json( + safeView( + await port.getProjectMatterProfile( + contextFor(request, options), + projectId(request), + ), + ), + ); + }), + ); + + router.post( + "/projects/:projectId/matter-profile", + asyncRoute(async (request, response) => { + response + .status(201) + .json( + safeView( + await port.createProjectMatterProfile( + contextFor(request, options), + projectId(request), + parseCreateMatterProfileWire(request.body), + ), + ), + ); + }), + ); + + router.patch( + "/projects/:projectId/matter-profile", + asyncRoute(async (request, response) => { + response.json( + safeView( + await port.updateProjectMatterProfile( + contextFor(request, options), + projectId(request), + parseUpdateMatterProfileWire(request.body), + ), + ), + ); + }), + ); + + router.use( + ( + error: unknown, + _request: Request, + response: Response, + next: NextFunction, + ) => { + if (response.headersSent) return next(error); + const mapped = errorPayload(error); + response.status(mapped.status).json(mapped.body); + }, + ); + + return router; +} diff --git a/backend/src/matter/profile/service.ts b/backend/src/matter/profile/service.ts new file mode 100644 index 0000000..68e15b0 --- /dev/null +++ b/backend/src/matter/profile/service.ts @@ -0,0 +1,324 @@ +import { randomUUID } from "node:crypto"; +import { ZodError } from "zod"; + +import { WorkspaceApiError } from "../../lib/workspace/errors"; +import type { WorkspaceDatabaseAdapter } from "../../lib/workspace/migrations"; +import { WORKSPACE_LOCAL_PRINCIPAL_ID } from "../../lib/workspace/principal"; +import type { WorkspaceInferenceActivityScope } from "../../lib/workspace/jobs/types"; +import { + type ProjectInferenceActivityReadPort, + ProjectsRepository, +} from "../../lib/workspace/repositories/projects"; +import { WorkspaceIdSchema } from "../../lib/workspace/workspacePersistencePrimitivesV1"; +import { + CreateMatterProfileRequestSchema, + CreateMatterRequestSchema, + MatterListRequestSchema, + MatterProfileSchema, + UpdateMatterProfileRequestSchema, + safeMatterValidationDetails, + type CreateMatterProfileRequest, +} from "./contracts"; +import type { MatterOverviewReadPort } from "./overviewRepository"; +import type { MatterProfilePersistencePort } from "./repository"; + +export type MatterProfileServiceContext = { principalId: string }; + +export type MatterProfileServiceOptions = { + clock?: () => Date; + nextId?: () => string; + acceptingRequests?: () => boolean; +}; + +/** Narrow policy seam: no job payload, controller, or cancellation authority. */ +export interface ProjectInferenceActivityPort { + hasBlockingInferenceWork(projectId: string): boolean; +} + +/** + * Composes the canonical Project ownership query with a copied handler + * registry snapshot. The synchronous call is made inside `BEGIN IMMEDIATE`. + */ +export function createProjectInferenceActivityPort( + projects: ProjectInferenceActivityReadPort, + activeInferenceScopes: () => readonly WorkspaceInferenceActivityScope[], +): ProjectInferenceActivityPort { + return Object.freeze({ + hasBlockingInferenceWork(projectId: string) { + return projects.hasBlockingInferenceJobs( + projectId, + activeInferenceScopes(), + ); + }, + }); +} + +function internal(message: string): never { + throw new WorkspaceApiError(500, "INTERNAL_ERROR", message); +} + +function nextTimestamp(now: string, priorValues: readonly string[]): string { + const nowMillis = Date.parse(now); + const priorMillis = priorValues.map((value) => Date.parse(value)); + if ( + !Number.isFinite(nowMillis) || + priorMillis.some((value) => !Number.isFinite(value)) + ) { + internal("Matter Profile timestamp state is invalid."); + } + const nextMillis = Math.max( + nowMillis, + ...priorMillis.map((value) => value + 1), + ); + try { + return new Date(nextMillis).toISOString(); + } catch { + internal("Matter Profile timestamp state is invalid."); + } +} + +function profileForCreate( + projectId: string, + request: CreateMatterProfileRequest, + now: string, +) { + return MatterProfileSchema.parse({ + projectId, + workspaceType: request.workspaceType, + clientName: request.clientName ?? null, + jurisdiction: request.jurisdiction ?? null, + representedRole: request.representedRole ?? null, + objective: request.objective ?? null, + createdAt: now, + updatedAt: now, + }); +} + +/** + * Sole Matter write-transaction coordinator. Profile persistence, canonical + * Project ownership and read-only overview projection remain separate ports. + */ +export class MatterProfileService { + private readonly clock: () => Date; + private readonly nextId: () => string; + private readonly accepting: () => boolean; + + constructor( + private readonly database: WorkspaceDatabaseAdapter, + private readonly projects: ProjectsRepository, + private readonly profiles: MatterProfilePersistencePort, + private readonly overview: MatterOverviewReadPort, + private readonly inferenceActivity: ProjectInferenceActivityPort, + options: MatterProfileServiceOptions = {}, + ) { + if ( + projects.database !== database || + profiles.database !== database || + overview.database !== database + ) { + internal("Matter Profile repositories must share one database."); + } + this.clock = options.clock ?? (() => new Date()); + this.nextId = options.nextId ?? randomUUID; + this.accepting = options.acceptingRequests ?? (() => true); + } + + private requireAccess(context: MatterProfileServiceContext) { + if (context.principalId !== WORKSPACE_LOCAL_PRINCIPAL_ID) { + throw new WorkspaceApiError(403, "FORBIDDEN", "Workspace is local-only."); + } + if (!this.accepting()) { + throw new WorkspaceApiError( + 409, + "CONFLICT", + "Matter runtime is not accepting requests.", + ); + } + } + + private now(): string { + try { + return this.clock().toISOString(); + } catch { + throw new WorkspaceApiError( + 500, + "INTERNAL_ERROR", + "Matter Profile clock is unavailable.", + ); + } + } + + private transaction(operation: () => T): T { + this.database.exec("BEGIN IMMEDIATE"); + try { + const value = operation(); + this.database.exec("COMMIT"); + return value; + } catch (error) { + try { + this.database.exec("ROLLBACK"); + } catch { + // Preserve the primary failure. + } + throw error; + } + } + + private publicCall(operation: () => T): T { + try { + return operation(); + } catch (error) { + if (error instanceof WorkspaceApiError) throw error; + if (error instanceof ZodError) { + throw new WorkspaceApiError( + 400, + "VALIDATION_ERROR", + "Matter Profile request is invalid.", + safeMatterValidationDetails(error), + ); + } + throw new WorkspaceApiError( + 500, + "INTERNAL_ERROR", + "Matter Profile operation failed.", + ); + } + } + + private assertConversionSafe(projectId: string) { + if (this.inferenceActivity.hasBlockingInferenceWork(projectId)) { + throw new WorkspaceApiError( + 409, + "CONFLICT", + "Matter conversion is unavailable while inference work is active.", + ); + } + } + + health() { + return this.publicCall(() => this.profiles.readiness()); + } + + listMatters(context: MatterProfileServiceContext, value: unknown = {}) { + return this.publicCall(() => { + this.requireAccess(context); + return this.overview.list(MatterListRequestSchema.parse(value)); + }); + } + + getMatter(context: MatterProfileServiceContext, projectId: string) { + return this.publicCall(() => { + this.requireAccess(context); + return this.overview.require(WorkspaceIdSchema.parse(projectId)); + }); + } + + getProjectMatterProfile( + context: MatterProfileServiceContext, + projectId: string, + ) { + return this.getMatter(context, projectId); + } + + createMatter(context: MatterProfileServiceContext, value: unknown) { + return this.publicCall(() => { + this.requireAccess(context); + const request = CreateMatterRequestSchema.parse(value); + const projectId = this.nextId(); + if (!WorkspaceIdSchema.safeParse(projectId).success) { + throw new WorkspaceApiError( + 500, + "INTERNAL_ERROR", + "Matter identity generation failed.", + ); + } + const now = this.now(); + return this.transaction(() => { + if (this.projects.get(projectId) !== null) { + throw new WorkspaceApiError( + 409, + "CONFLICT", + "Matter identity already exists.", + ); + } + this.projects.create({ + id: projectId, + name: request.name, + description: request.description ?? null, + cmNumber: request.cmNumber ?? null, + practice: request.practice ?? null, + now, + }); + this.profiles.insert(profileForCreate(projectId, request, now)); + return this.overview.require(projectId); + }); + }); + } + + createProjectMatterProfile( + context: MatterProfileServiceContext, + projectId: string, + value: unknown, + ) { + return this.publicCall(() => { + this.requireAccess(context); + const parsedProjectId = WorkspaceIdSchema.parse(projectId); + const request = CreateMatterProfileRequestSchema.parse(value); + return this.transaction(() => { + const project = this.projects.requireActive(parsedProjectId); + if (this.profiles.get(parsedProjectId) !== null) { + throw new WorkspaceApiError( + 409, + "CONFLICT", + "Matter Profile already exists.", + ); + } + this.assertConversionSafe(parsedProjectId); + const timestamp = nextTimestamp(this.now(), [project.updatedAt]); + this.profiles.insert( + profileForCreate(parsedProjectId, request, timestamp), + ); + this.projects.update(parsedProjectId, { now: timestamp }); + return this.overview.require(parsedProjectId); + }); + }); + } + + updateProjectMatterProfile( + context: MatterProfileServiceContext, + projectId: string, + value: unknown, + ) { + return this.publicCall(() => { + this.requireAccess(context); + const parsedProjectId = WorkspaceIdSchema.parse(projectId); + const request = UpdateMatterProfileRequestSchema.parse(value); + return this.transaction(() => { + const project = this.projects.requireActive(parsedProjectId); + const existing = this.profiles.require(parsedProjectId); + if ( + existing.workspaceType === null && + request.workspaceType === undefined + ) { + throw new WorkspaceApiError( + 400, + "VALIDATION_ERROR", + "Matter workspace classification is required.", + ); + } + const timestamp = nextTimestamp(this.now(), [ + project.updatedAt, + existing.updatedAt, + ]); + const profile = MatterProfileSchema.parse({ + ...existing, + ...request, + updatedAt: timestamp, + }); + this.profiles.update(profile); + this.projects.update(parsedProjectId, { now: timestamp }); + return this.overview.require(parsedProjectId); + }); + }); + } +} diff --git a/backend/src/scripts/veraMatterInferencePolicyAudit.ts b/backend/src/scripts/veraMatterInferencePolicyAudit.ts new file mode 100644 index 0000000..9294a9e --- /dev/null +++ b/backend/src/scripts/veraMatterInferencePolicyAudit.ts @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; + +import { WorkspaceApiError } from "../lib/workspace/errors"; +import type { WorkspaceDatabaseAdapter } from "../lib/workspace/migrations"; +import type { + AssistantToolContext, + AssistantToolPort, +} from "../lib/workspace/services/assistantRuntime"; +import type { WorkflowStepExecutor } from "../lib/workspace/services/workflowRuntime"; +import { + MATTER_INFERENCE_POLICY_MESSAGE, + MatterInferencePolicyGate, + MatterPolicyAssistantToolPort, + MatterPolicyWorkflowStepExecutor, +} from "../matter/inferencePolicy"; + +const genericProjectId = "00000000-0000-4000-8000-000000000101"; +const matterProjectId = "00000000-0000-4000-8000-000000000102"; +const matterIds = new Set([matterProjectId]); +let policyQueries = 0; + +const database = { + exec() {}, + prepare(sql: string) { + assert.match(sql, /FROM matter_profiles WHERE project_id = \?/); + return { + run() { + return undefined; + }, + get(projectId: unknown) { + policyQueries += 1; + return matterIds.has(String(projectId)) ? { present: 1 } : undefined; + }, + all() { + return []; + }, + }; + }, +} satisfies WorkspaceDatabaseAdapter; + +function assertClosed(operation: () => unknown) { + assert.throws(operation, (error: unknown) => { + assert(error instanceof WorkspaceApiError); + assert.equal(error.status, 412); + assert.equal(error.code, "PRECONDITION_FAILED"); + assert.equal(error.message, MATTER_INFERENCE_POLICY_MESSAGE); + assert(!error.message.includes(matterProjectId)); + assert(!error.message.toLowerCase().includes("select")); + return true; + }); +} + +function assistantContext(projectId: string | null): AssistantToolContext { + return { + jobId: "00000000-0000-4000-8000-000000000201", + attempt: 1, + chatId: "00000000-0000-4000-8000-000000000202", + projectId, + modelProfileId: "00000000-0000-4000-8000-000000000203", + documents: [], + }; +} + +async function main() { + const gate = new MatterInferencePolicyGate(database); + assert.equal(gate.state(null), "workspace_compatibility"); + assert.equal(gate.state(genericProjectId), "workspace_compatibility"); + assert.equal(gate.state(matterProjectId), "policy_gate_closed"); + assertClosed(() => gate.assertProjectModelUse(matterProjectId)); + assertClosed(() => gate.assertProjectModelUse("not-a-project-id")); + gate.assertProjectModelUse(null); + gate.assertProjectModelUse(genericProjectId); + + let assistantDelegateAssertions = 0; + const assistantDelegate: AssistantToolPort = { + assertModelUse() { + assistantDelegateAssertions += 1; + }, + async registeredTools() { + return { adapterId: "audit-tools", tools: [] }; + }, + async execute() { + return { content: "ok" }; + }, + }; + const assistant = new MatterPolicyAssistantToolPort( + assistantDelegate, + gate, + ); + await assistant.assertModelUse(assistantContext(null)); + await assistant.assertModelUse(assistantContext(genericProjectId)); + await assert.rejects( + assistant.assertModelUse(assistantContext(matterProjectId)), + (error: unknown) => + error instanceof WorkspaceApiError && + error.code === "PRECONDITION_FAILED", + ); + assert.equal( + assistantDelegateAssertions, + 2, + "a closed Matter must not reach the underlying Assistant tool boundary", + ); + + let prepared = 0; + let executed = 0; + const workflowDelegate: WorkflowStepExecutor = { + prepareStep() { + prepared += 1; + return { status: "ready", input: { prepared: true } }; + }, + executeStep() { + executed += 1; + return { status: "complete", output: { complete: true } }; + }, + }; + const workflow = new MatterPolicyWorkflowStepExecutor( + workflowDelegate, + gate, + ); + const workflowInput = ( + projectId: string | null, + kind: "prompt" | "document_context", + ) => + ({ + snapshot: { projectId }, + step: { kind }, + ordinal: 0, + stepInput: {}, + history: [], + signal: new AbortController().signal, + }) as unknown as Parameters[0]; + + const preparation = await workflow.prepareStep( + workflowInput(genericProjectId, "prompt"), + ); + assert.deepEqual(preparation, { + status: "ready", + input: { prepared: true }, + }); + assert.equal(prepared, 1); + assertClosed(() => + workflow.executeStep(workflowInput(matterProjectId, "prompt")), + ); + assert.equal(executed, 0); + await workflow.executeStep(workflowInput(matterProjectId, "document_context")); + await workflow.executeStep(workflowInput(genericProjectId, "prompt")); + await workflow.executeStep(workflowInput(null, "prompt")); + assert.equal(executed, 3); + assert(policyQueries >= 8, "every project-scoped boundary must re-query policy"); + + console.log( + JSON.stringify( + { + ok: true, + suite: "vera-matter-inference-policy-audit-v1", + checks: [ + "global Assistant compatibility", + "generic Project compatibility", + "Matter Assistant fail-closed before delegation", + "Matter prompt Workflow fail-closed before delegation", + "non-model Workflow steps remain available", + "redacted deterministic precondition error", + "fresh database lookup at every project-scoped boundary", + ], + }, + null, + 2, + ), + ); +} + +void main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/backend/src/scripts/veraMatterProfileModuleAudit.ts b/backend/src/scripts/veraMatterProfileModuleAudit.ts new file mode 100644 index 0000000..87f88e5 --- /dev/null +++ b/backend/src/scripts/veraMatterProfileModuleAudit.ts @@ -0,0 +1,2138 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import os from "node:os"; +import path from "node:path"; + +import express, { type Request } from "express"; + +import { WorkspaceDatabase } from "../lib/workspace/database"; +import { WorkspaceApiError } from "../lib/workspace/errors"; +import { + WORKSPACE_MIGRATIONS, + type WorkspaceDatabaseAdapter, +} from "../lib/workspace/migrations"; +import { WORKSPACE_LOCAL_PRINCIPAL_ID } from "../lib/workspace/principal"; +import { ProjectsRepository } from "../lib/workspace/repositories/projects"; +import { ChatsRepository } from "../lib/workspace/repositories/chats"; +import { WorkspaceJobsRepository } from "../lib/workspace/repositories/jobs"; +import { TabularRepository } from "../lib/workspace/repositories/tabular"; +import { WorkflowsRepository } from "../lib/workspace/repositories/workflows"; +import { + WorkspaceJobAbortRegistry, + WorkspaceJobRuntime, + WorkspaceJobsService, +} from "../lib/workspace/services/jobs"; +import { WorkspaceJobEnqueuerAdapter } from "../lib/workspace/services/jobEnqueuer"; +import { CanonicalProjectInferenceScopeResolver } from "../lib/workspace/services/projectInferenceScope"; +import { WorkflowsService } from "../lib/workspace/services/workflows"; +import { MatterInferencePolicyGate } from "../matter/inferencePolicy"; +import { + MatterProfileWireSchema, + MatterViewPageWireSchema, + MatterViewWireSchema, + toMatterViewWire, +} from "../matter/profile/contracts"; +import { createMatterProfileModule } from "../matter/profile"; +import { MatterOverviewRepository } from "../matter/profile/overviewRepository"; +import { MatterProfileRepository } from "../matter/profile/repository"; +import { createMatterProfileV1Router } from "../matter/profile/router"; +import { + createProjectInferenceActivityPort, + MatterProfileService, +} from "../matter/profile/service"; + +const NOW = "2026-07-16T10:00:00.000Z"; +const SECRET = "sk-audit-secret-value"; +const PRIVATE_PATH = "/private/Users/audit/matter.db"; +const localContext = { principalId: WORKSPACE_LOCAL_PRINCIPAL_ID }; + +function expectApiError( + operation: () => unknown, + status: number, + code?: string, +): WorkspaceApiError { + try { + operation(); + } catch (error) { + assert.ok(error instanceof WorkspaceApiError); + assert.equal(error.status, status); + if (code) assert.equal(error.code, code); + return error; + } + assert.fail(`Expected WorkspaceApiError ${status}.`); +} + +function assertRedacted(value: unknown) { + const encoded = JSON.stringify(value); + assert.equal(encoded.includes(SECRET), false); + assert.equal(encoded.includes(PRIVATE_PATH), false); +} + +function createProject( + projects: ProjectsRepository, + input: { + id?: string; + name: string; + description?: string | null; + cmNumber?: string | null; + practice?: string | null; + now?: string; + }, +) { + const id = input.id ?? randomUUID(); + projects.create({ + id, + name: input.name, + description: input.description ?? null, + cmNumber: input.cmNumber ?? null, + practice: input.practice ?? null, + now: input.now ?? NOW, + }); + return id; +} + +function seedOverviewCounts(database: WorkspaceDatabase, projectId: string) { + database + .prepare( + `INSERT INTO documents ( + id, project_id, title, filename, mime_type, size_bytes + ) VALUES (?, ?, 'Counted document', 'counted.txt', 'text/plain', 1)`, + ) + .run(randomUUID(), projectId); + database + .prepare( + `INSERT INTO chats (id, project_id, scope, title) + VALUES (?, ?, 'project', 'Counted chat')`, + ) + .run(randomUUID(), projectId); + database + .prepare( + `INSERT INTO tabular_reviews (id, project_id, title) + VALUES (?, ?, 'Counted review')`, + ) + .run(randomUUID(), projectId); + database + .prepare( + `INSERT INTO workflows (id, project_id, type, title) + VALUES (?, ?, 'assistant', 'Counted workflow')`, + ) + .run(randomUUID(), projectId); +} + +type ModelJobType = "assistant_generate" | "workflow_run" | "tabular_cell"; +type JobStatus = + | "queued" + | "running" + | "complete" + | "failed" + | "cancelled" + | "interrupted"; + +function insertScopedJob( + database: WorkspaceDatabase, + input: { + projectId: string; + type: ModelJobType | "document_parse"; + status: JobStatus; + }, +) { + const jobId = randomUUID(); + let resourceType: "document" | "chat" | "workflow_run" | "tabular_cell"; + let resourceId: string; + + if (input.type === "assistant_generate") { + resourceType = "chat"; + resourceId = randomUUID(); + database + .prepare( + `INSERT INTO chats (id, project_id, scope, title, created_at, updated_at) + VALUES (?, ?, 'project', 'Matter conversion job audit', ?, ?)`, + ) + .run(resourceId, input.projectId, NOW, NOW); + } else if (input.type === "workflow_run") { + const workflowId = randomUUID(); + resourceType = "workflow_run"; + resourceId = randomUUID(); + database + .prepare( + `INSERT INTO workflows (id, project_id, type, title, created_at, updated_at) + VALUES (?, ?, 'assistant', 'Matter conversion job audit', ?, ?)`, + ) + .run(workflowId, input.projectId, NOW, NOW); + database + .prepare( + `INSERT INTO workflow_runs ( + id, workflow_id, project_id, status, created_at, updated_at + ) VALUES (?, ?, ?, 'queued', ?, ?)`, + ) + .run(resourceId, workflowId, input.projectId, NOW, NOW); + } else { + const documentId = randomUUID(); + database + .prepare( + `INSERT INTO documents ( + id, project_id, title, filename, mime_type, size_bytes, + created_at, updated_at + ) VALUES (?, ?, 'Matter conversion job audit', 'audit.txt', + 'text/plain', 1, ?, ?)`, + ) + .run(documentId, input.projectId, NOW, NOW); + if (input.type === "document_parse") { + resourceType = "document"; + resourceId = documentId; + } else { + const reviewId = randomUUID(); + const columnId = randomUUID(); + resourceType = "tabular_cell"; + resourceId = randomUUID(); + database + .prepare( + `INSERT INTO tabular_reviews ( + id, project_id, title, created_at, updated_at + ) VALUES (?, ?, 'Matter conversion job audit', ?, ?)`, + ) + .run(reviewId, input.projectId, NOW, NOW); + database + .prepare( + `INSERT INTO tabular_review_columns ( + id, review_id, key, title, output_type, ordinal, + created_at, updated_at + ) VALUES (?, ?, 'audit', 'Audit', 'text', 0, ?, ?)`, + ) + .run(columnId, reviewId, NOW, NOW); + database + .prepare( + `INSERT INTO tabular_review_documents ( + review_id, document_id, ordinal, created_at + ) VALUES (?, ?, 0, ?)`, + ) + .run(reviewId, documentId, NOW); + database + .prepare( + `INSERT INTO tabular_cells ( + id, review_id, document_id, column_id, output_type, + created_at, updated_at + ) VALUES (?, ?, ?, ?, 'text', ?, ?)`, + ) + .run(resourceId, reviewId, documentId, columnId, NOW, NOW); + } + } + + database + .prepare( + `INSERT INTO jobs ( + id, type, status, resource_type, resource_id, attempt, max_attempts, + retryable, payload_json, scheduled_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, 3, 1, '{}', ?, ?, ?)`, + ) + .run( + jobId, + input.type, + input.status, + resourceType, + resourceId, + input.status === "running" ? 1 : 0, + NOW, + NOW, + NOW, + ); + return jobId; +} + +function seedResolverModelProfile(database: WorkspaceDatabase) { + const id = randomUUID(); + database + .prepare( + `INSERT INTO model_profiles ( + id, name, provider, model, credential_status, credential_state, + capabilities_json, settings_json, enabled, is_default, + connection_revision, created_at, updated_at + ) VALUES (?, ?, 'openai', 'resolver-audit', 'not_configured', 'missing', + ?, '{}', 1, 0, 0, ?, ?)`, + ) + .run( + id, + `Resolver ${id}`, + JSON.stringify({ + streaming: true, + toolCalling: true, + structuredOutput: true, + vision: false, + }), + NOW, + NOW, + ); + return id; +} + +function insertDurableRunningJob( + database: WorkspaceDatabase, + input: { + id: string; + type: ModelJobType; + resourceType: "chat" | "workflow_run" | "tabular_cell"; + resourceId: string; + payload: unknown; + }, +) { + database + .prepare( + `INSERT INTO jobs ( + id, type, status, resource_type, resource_id, attempt, max_attempts, + retryable, payload_json, scheduled_at, queued_at, locked_at, + started_at, lease_owner, lease_expires_at, created_at, updated_at + ) VALUES (?, ?, 'running', ?, ?, 1, 3, 1, ?, ?, ?, ?, ?, + 'resolver-audit-owner', '2028-01-01T00:00:00.000Z', ?, ?)`, + ) + .run( + input.id, + input.type, + input.resourceType, + input.resourceId, + JSON.stringify(input.payload), + NOW, + NOW, + NOW, + NOW, + NOW, + NOW, + ); +} + +type HttpResult = { + status: number; + body: unknown; +}; + +async function requestJson( + origin: string, + pathname: string, + input: { + method?: string; + body?: unknown; + principal?: string | null; + } = {}, +): Promise { + const headers: Record = {}; + if (input.body !== undefined) headers["content-type"] = "application/json"; + if (input.principal !== null) { + headers["x-audit-principal"] = + input.principal ?? WORKSPACE_LOCAL_PRINCIPAL_ID; + } + const response = await fetch(`${origin}${pathname}`, { + method: input.method ?? "GET", + headers, + ...(input.body === undefined ? {} : { body: JSON.stringify(input.body) }), + }); + return { + status: response.status, + body: (await response.json()) as unknown, + }; +} + +async function closeServer(server: Server | null) { + if (!server) return; + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); +} + +function assertProfileWireKeys(value: unknown) { + const profile = MatterProfileWireSchema.parse(value); + assert.deepEqual(Object.keys(profile).sort(), [ + "client_name", + "created_at", + "jurisdiction", + "objective", + "project_id", + "represented_role", + "updated_at", + "workspace_type", + ]); + for (const forbidden of [ + "matter_type", + "counterparty", + "court", + "case_number", + "stage", + "risk_level", + "opened_at", + "closed_at", + ]) { + assert.equal(forbidden in profile, false); + } +} + +async function main() { + const originalEncryption = process.env.ALETHEIA_DATABASE_ENCRYPTION; + const root = mkdtempSync( + path.join(os.tmpdir(), "vera-matter-profile-module-audit-"), + ); + const databasePath = path.join(root, "workspace.db"); + const v15OnlyPath = path.join(root, "v15-only.db"); + let database: WorkspaceDatabase | null = null; + let server: Server | null = null; + + try { + process.env.ALETHEIA_DATABASE_ENCRYPTION = "metadata_plaintext"; + + // A v15-only database is not ready for the v16 public contract. + const v15Only = new WorkspaceDatabase(v15OnlyPath, { + migrations: WORKSPACE_MIGRATIONS.slice(0, 15), + }); + assert.equal(v15Only.migration?.currentVersion, 15); + const v15Readiness = expectApiError( + () => new MatterProfileRepository(v15Only).readiness(), + 500, + "INTERNAL_ERROR", + ); + assertRedacted(v15Readiness.toResponse()); + v15Only.close(); + + // Seed a genuine pre-v16 profile. The v16 migration must preserve it with + // workspace_type NULL and expose classification_required without guessing. + const v15 = new WorkspaceDatabase(databasePath, { + migrations: WORKSPACE_MIGRATIONS.slice(0, 15), + }); + const v15Projects = new ProjectsRepository(v15); + const legacyProjectId = createProject(v15Projects, { + name: "Legacy classified-by-v15-only Project", + cmNumber: "LEGACY-001", + practice: "Disputes", + }); + v15 + .prepare( + `INSERT INTO matter_profiles ( + project_id, matter_type, client_name, represented_role, + counterparty, court, case_number, stage, objective, risk_level, + opened_at, closed_at, created_at, updated_at + ) VALUES (?, 'civil_litigation', 'Legacy Client', 'Counsel', + 'Hidden Counterparty', 'Hidden Court', 'Hidden Case', + 'Hidden Stage', 'Legacy objective', 'high', NULL, NULL, ?, ?)`, + ) + .run(legacyProjectId, NOW, NOW); + v15.close(); + + database = new WorkspaceDatabase(databasePath); + assert.equal(database.migration?.currentVersion, 16); + const projects = new ProjectsRepository(database); + const repository = new MatterProfileRepository(database); + const overview = new MatterOverviewRepository(database); + const abortRegistry = new WorkspaceJobAbortRegistry(); + const inferenceActivity = createProjectInferenceActivityPort(projects, () => + abortRegistry.activeInferenceScopes(), + ); + const service = new MatterProfileService( + database, + projects, + repository, + overview, + inferenceActivity, + { clock: () => new Date(NOW) }, + ); + const facade = createMatterProfileModule(database, projects, { + activeInferenceScopes: () => abortRegistry.activeInferenceScopes(), + clock: () => new Date(NOW), + acceptingRequests: () => true, + }); + assert.deepEqual(Object.keys(facade).sort(), [ + "api", + "createRouter", + "health", + ]); + assert.deepEqual(facade.health(), { + status: "ready", + schemaVersion: 16, + inferencePolicy: "gate_closed", + }); + assert.equal("policyMode" in facade.health(), false); + + const legacy = service.getMatter(localContext, legacyProjectId); + assert.equal(legacy.profile?.workspaceType, null); + assert.equal(legacy.profileState, "classification_required"); + assert.deepEqual(legacy.capabilities, { + matterProfile: "classify", + inference: "policy_gate_closed", + review: "unavailable", + drafts: "document_scoped", + }); + const legacyWire = toMatterViewWire(legacy); + assertProfileWireKeys(legacyWire.matter_profile); + assert.equal(legacyWire.matter_profile?.workspace_type, null); + const legacyEncoded = JSON.stringify(legacyWire); + for (const hiddenValue of [ + "Hidden Counterparty", + "Hidden Court", + "Hidden Case", + "Hidden Stage", + ]) { + assert.equal(legacyEncoded.includes(hiddenValue), false); + } + expectApiError( + () => + service.updateProjectMatterProfile(localContext, legacyProjectId, { + objective: "Classification cannot be bypassed", + }), + 400, + "VALIDATION_ERROR", + ); + assert.equal( + repository.require(legacyProjectId).objective, + "Legacy objective", + ); + + // Generic Projects, including historical empty nullable text, remain + // explicit absent profiles and retain Workspace-compatible inference. + const emptyGenericId = createProject(projects, { + name: "Historical generic Project", + description: "", + cmNumber: "", + practice: "", + now: "2027-01-01T00:00:00.000+08:00", + }); + const emptyGeneric = service.getMatter(localContext, emptyGenericId); + assert.equal(emptyGeneric.profile, null); + assert.equal(emptyGeneric.profileState, "absent"); + assert.equal(emptyGeneric.project.description, ""); + assert.equal(emptyGeneric.project.cmNumber, ""); + assert.equal(emptyGeneric.project.practice, ""); + assert.deepEqual(emptyGeneric.capabilities, { + matterProfile: "create", + inference: "workspace_compatibility", + review: "unavailable", + drafts: "document_scoped", + }); + + // Generic -> Matter conversion is serialized with inference enqueue/claim. + // Each model-producing job type and each active durable state blocks, does + // not create a profile, and is neither cancelled nor mutated. + for (const type of [ + "assistant_generate", + "workflow_run", + "tabular_cell", + ] as const) { + for (const status of ["queued", "running"] as const) { + const projectId = createProject(projects, { + name: `${type} ${status} conversion gate`, + }); + const jobId = insertScopedJob(database, { projectId, type, status }); + if (type === "assistant_generate" && status === "running") { + database + .prepare( + "UPDATE jobs SET cancel_requested_at = ?, cancellation_reason = ? WHERE id = ?", + ) + .run(NOW, "Audit cancellation already requested", jobId); + } + const blocked = expectApiError( + () => + service.createProjectMatterProfile(localContext, projectId, { + workspaceType: "general_legal", + }), + 409, + "CONFLICT", + ); + assert.equal( + blocked.message, + "Matter conversion is unavailable while inference work is active.", + ); + assert.equal(repository.get(projectId), null); + const persisted = database + .prepare("SELECT status, cancel_requested_at FROM jobs WHERE id = ?") + .get(jobId); + assert.equal(persisted?.status, status); + assert.equal( + persisted?.cancel_requested_at, + type === "assistant_generate" && status === "running" ? NOW : null, + ); + } + } + + // A registered non-model parse handler is outside this conversion gate. + const parseProjectId = createProject(projects, { + name: "Registered document parse remains compatible", + }); + const parseJobId = insertScopedJob(database, { + projectId: parseProjectId, + type: "document_parse", + status: "running", + }); + const parseController = new AbortController(); + abortRegistry.register(parseJobId, parseController); + const parsedConversion = service.createProjectMatterProfile( + localContext, + parseProjectId, + { workspaceType: "research" }, + ); + assert.equal(parsedConversion.profileState, "ready"); + assert.equal(parseController.signal.aborted, false); + assert.equal( + database.prepare("SELECT status FROM jobs WHERE id = ?").get(parseJobId) + ?.status, + "running", + ); + abortRegistry.unregister(parseJobId, parseController); + + // Terminal persisted model work is normally history and does not block. + for (const type of [ + "assistant_generate", + "workflow_run", + "tabular_cell", + ] as const) { + const projectId = createProject(projects, { + name: `${type} terminal conversion compatibility`, + }); + insertScopedJob(database, { projectId, type, status: "complete" }); + assert.equal( + service.createProjectMatterProfile(localContext, projectId, { + workspaceType: "research", + }).profileState, + "ready", + ); + } + + // Workflow and Tabular cancellation can persist a terminal state before a + // delayed provider fully unwinds. Registration keeps conversion closed + // until the handler's finally block unregisters, without aborting it here. + for (const type of ["workflow_run", "tabular_cell"] as const) { + const projectId = createProject(projects, { + name: `${type} terminal but still executing`, + }); + const jobId = insertScopedJob(database, { + projectId, + type, + status: "cancelled", + }); + const controller = new AbortController(); + abortRegistry.register(jobId, controller, { + jobId, + type, + scope: "project", + projectId, + }); + expectApiError( + () => + service.createProjectMatterProfile(localContext, projectId, { + workspaceType: "general_legal", + }), + 409, + "CONFLICT", + ); + assert.equal(repository.get(projectId), null); + assert.equal(controller.signal.aborted, false); + assert.equal( + database.prepare("SELECT status FROM jobs WHERE id = ?").get(jobId) + ?.status, + "cancelled", + ); + abortRegistry.unregister(jobId, controller); + assert.equal( + service.createProjectMatterProfile(localContext, projectId, { + workspaceType: "general_legal", + }).profileState, + "ready", + ); + } + + // A fenced lease retry can overlap the provider call from its predecessor. + // Registry identity is therefore controller-instance, not only job id. + const overlappingProjectId = createProject(projects, { + name: "Overlapping fenced inference attempts", + }); + const overlappingJobId = insertScopedJob(database, { + projectId: overlappingProjectId, + type: "assistant_generate", + status: "cancelled", + }); + const oldAttempt = new AbortController(); + const newAttempt = new AbortController(); + const oldScope = { + jobId: overlappingJobId, + type: "assistant_generate" as const, + scope: "project" as const, + projectId: overlappingProjectId, + }; + const newScope = { + jobId: overlappingJobId, + type: "assistant_generate" as const, + scope: "unresolved" as const, + projectId: null, + }; + abortRegistry.register(overlappingJobId, oldAttempt, oldScope); + abortRegistry.register(overlappingJobId, newAttempt, newScope); + assert.deepEqual( + abortRegistry + .activeInferenceScopes() + .filter((scope) => scope.jobId === overlappingJobId), + [oldScope, newScope], + ); + + // New finishes first: the old provider still owns its Project scope. + abortRegistry.unregister(overlappingJobId, newAttempt); + expectApiError( + () => + service.createProjectMatterProfile(localContext, overlappingProjectId, { + workspaceType: "general_legal", + }), + 409, + "CONFLICT", + ); + assert.deepEqual( + abortRegistry + .activeInferenceScopes() + .filter((scope) => scope.jobId === overlappingJobId), + [oldScope], + ); + + // Old finishes first on another overlap: the new unresolved attempt still + // fails closed until its own controller-identity finally runs. + abortRegistry.register(overlappingJobId, newAttempt, newScope); + abortRegistry.unregister(overlappingJobId, oldAttempt); + expectApiError( + () => + service.createProjectMatterProfile(localContext, overlappingProjectId, { + workspaceType: "general_legal", + }), + 409, + "CONFLICT", + ); + assert.deepEqual( + abortRegistry + .activeInferenceScopes() + .filter((scope) => scope.jobId === overlappingJobId), + [newScope], + ); + + // Cancellation fans out to every live attempt. An aborted controller stays + // registered (and blocks conversion) until that handler actually unwinds. + abortRegistry.register(overlappingJobId, oldAttempt, oldScope); + assert.equal(abortRegistry.abort(overlappingJobId), true); + assert.equal(oldAttempt.signal.aborted, true); + assert.equal(newAttempt.signal.aborted, true); + abortRegistry.abortAll(); + assert.deepEqual( + abortRegistry + .activeInferenceScopes() + .filter((scope) => scope.jobId === overlappingJobId), + [newScope, oldScope], + ); + abortRegistry.unregister(overlappingJobId, newAttempt); + expectApiError( + () => + service.createProjectMatterProfile(localContext, overlappingProjectId, { + workspaceType: "general_legal", + }), + 409, + "CONFLICT", + ); + abortRegistry.unregister(overlappingJobId, oldAttempt); + assert.equal(abortRegistry.abort(overlappingJobId), false); + assert.equal( + service.createProjectMatterProfile(localContext, overlappingProjectId, { + workspaceType: "general_legal", + }).profileState, + "ready", + ); + + // Frozen scope survives deletion of the live owner graph. This models the + // exact cancel -> durable terminal -> owner delete -> delayed provider + // unwind window for all three model-producing handlers. + for (const type of [ + "assistant_generate", + "workflow_run", + "tabular_cell", + ] as const) { + const projectId = createProject(projects, { + name: `${type} deleted owner while handler is active`, + }); + const jobId = insertScopedJob(database, { + projectId, + type, + status: "running", + }); + const resource = database + .prepare("SELECT resource_id FROM jobs WHERE id = ?") + .get(jobId); + assert.equal(typeof resource?.resource_id, "string"); + const resourceId = String(resource?.resource_id); + const controller = new AbortController(); + abortRegistry.register(jobId, controller, { + jobId, + type, + scope: "project", + projectId, + }); + database + .prepare( + `UPDATE jobs + SET status = 'cancelled', cancel_requested_at = ?, + cancellation_reason = ?, completed_at = ?, updated_at = ? + WHERE id = ?`, + ) + .run(NOW, "Audit cancellation", NOW, NOW, jobId); + if (type === "assistant_generate") { + database.prepare("DELETE FROM chats WHERE id = ?").run(resourceId); + } else if (type === "workflow_run") { + const run = database + .prepare("SELECT workflow_id FROM workflow_runs WHERE id = ?") + .get(resourceId); + assert.equal(typeof run?.workflow_id, "string"); + database + .prepare("DELETE FROM workflow_runs WHERE id = ?") + .run(resourceId); + database + .prepare("DELETE FROM workflows WHERE id = ?") + .run(String(run?.workflow_id)); + } else { + const cell = database + .prepare( + "SELECT review_id, document_id FROM tabular_cells WHERE id = ?", + ) + .get(resourceId); + assert.equal(typeof cell?.review_id, "string"); + database + .prepare("DELETE FROM tabular_cells WHERE id = ?") + .run(resourceId); + database + .prepare("DELETE FROM tabular_reviews WHERE id = ?") + .run(String(cell?.review_id)); + database + .prepare("DELETE FROM documents WHERE id = ?") + .run(String(cell?.document_id)); + } + assert.equal(projects.hasBlockingInferenceJobs(projectId, []), false); + expectApiError( + () => + service.createProjectMatterProfile(localContext, projectId, { + workspaceType: "general_legal", + }), + 409, + "CONFLICT", + ); + assert.equal(repository.get(projectId), null); + assert.equal(controller.signal.aborted, false); + abortRegistry.unregister(jobId, controller); + assert.equal( + service.createProjectMatterProfile(localContext, projectId, { + workspaceType: "general_legal", + }).profileState, + "ready", + ); + } + + // Producer-side provenance coverage: resolve all three scopes from their + // real durable execution contracts, never from arbitrary payload alone. + const resolverModelId = seedResolverModelProfile(database); + const resolverJobs = new WorkspaceJobsRepository(database); + const resolverWorkflows = new WorkflowsService( + new WorkflowsRepository(database), + new WorkspaceJobEnqueuerAdapter(new WorkspaceJobsService(resolverJobs)), + ); + const scopeResolver = new CanonicalProjectInferenceScopeResolver( + new ChatsRepository(database), + resolverWorkflows, + new TabularRepository(database), + ); + + const seedAssistantContract = (projectId: string | null) => { + const chatId = randomUUID(); + const promptMessageId = randomUUID(); + const outputMessageId = randomUUID(); + const jobId = randomUUID(); + database! + .prepare( + `INSERT INTO chats ( + id, project_id, scope, title, created_at, updated_at + ) VALUES (?, ?, ?, 'Resolver Assistant', ?, ?)`, + ) + .run( + chatId, + projectId, + projectId === null ? "global" : "project", + NOW, + NOW, + ); + database! + .prepare( + `INSERT INTO chat_messages ( + id, chat_id, sequence, role, content, status, + created_at, updated_at, completed_at + ) VALUES (?, ?, 0, 'user', 'Resolver prompt', 'complete', ?, ?, ?)`, + ) + .run(promptMessageId, chatId, NOW, NOW, NOW); + const payload = { + schema: "vera-assistant-generation-v1", + chatId, + projectId, + promptMessageId, + outputMessageId, + modelProfileId: resolverModelId, + documents: [], + retrieval: { currentVersionOnly: true, limit: 10 }, + }; + insertDurableRunningJob(database!, { + id: jobId, + type: "assistant_generate", + resourceType: "chat", + resourceId: chatId, + payload, + }); + database! + .prepare( + `INSERT INTO chat_messages ( + id, chat_id, sequence, role, content, status, model_profile_id, + job_id, created_at, updated_at + ) VALUES (?, ?, 1, 'assistant', '', 'pending', ?, ?, ?, ?)`, + ) + .run(outputMessageId, chatId, resolverModelId, jobId, NOW, NOW); + database! + .prepare( + `INSERT INTO assistant_generation_snapshots ( + job_id, chat_id, prompt_message_id, output_message_id, + model_profile_id, current_version_only, retrieval_limit, + created_at + ) VALUES (?, ?, ?, ?, ?, 1, 10, ?)`, + ) + .run( + jobId, + chatId, + promptMessageId, + outputMessageId, + resolverModelId, + NOW, + ); + const job = resolverJobs.getJob(jobId); + assert.ok(job); + return { job, chatId }; + }; + + const resolverAssistantProjectId = createProject(projects, { + name: "Resolver Assistant Project", + }); + const assistantContract = seedAssistantContract(resolverAssistantProjectId); + assert.deepEqual(scopeResolver.resolve(assistantContract.job), { + jobId: assistantContract.job.id, + type: "assistant_generate", + scope: "project", + projectId: resolverAssistantProjectId, + }); + const globalAssistantContract = seedAssistantContract(null); + assert.deepEqual(scopeResolver.resolve(globalAssistantContract.job), { + jobId: globalAssistantContract.job.id, + type: "assistant_generate", + scope: "global", + projectId: null, + }); + assert.equal( + scopeResolver.resolve({ + ...assistantContract.job, + payload: { schema: "tampered" }, + })?.scope, + "unresolved", + ); + + const resolverWorkflowProjectId = createProject(projects, { + name: "Resolver Workflow Project", + }); + const resolverWorkflowId = randomUUID(); + const resolverRunId = randomUUID(); + const resolverWorkflowJobId = randomUUID(); + const resolverSnapshotId = randomUUID(); + const resolverSnapshotSha = "a".repeat(64); + const resolverWorkflowPayload = { + runId: resolverRunId, + workflowId: resolverWorkflowId, + snapshotId: resolverSnapshotId, + snapshotSha256: resolverSnapshotSha, + retryOfRunId: null, + }; + insertDurableRunningJob(database, { + id: resolverWorkflowJobId, + type: "workflow_run", + resourceType: "workflow_run", + resourceId: resolverRunId, + payload: resolverWorkflowPayload, + }); + database + .prepare( + `INSERT INTO workflows ( + id, project_id, type, title, created_at, updated_at + ) VALUES (?, ?, 'assistant', 'Resolver Workflow', ?, ?)`, + ) + .run(resolverWorkflowId, resolverWorkflowProjectId, NOW, NOW); + database + .prepare( + `INSERT INTO workflow_runs ( + id, workflow_id, project_id, job_id, status, input_json, + started_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, 'running', '{}', ?, ?, ?)`, + ) + .run( + resolverRunId, + resolverWorkflowId, + resolverWorkflowProjectId, + resolverWorkflowJobId, + NOW, + NOW, + NOW, + ); + database + .prepare( + `INSERT INTO workflow_execution_snapshots ( + id, workflow_run_id, workflow_id, schema_version, + workflow_version, project_id, model_profile_id, config_json, + steps_json, skill_markdown, columns_config_json, + input_binding_json, snapshot_sha256, created_at + ) VALUES (?, ?, ?, 1, 'resolver-v1', ?, NULL, '{}', '[]', '', + '[]', '{}', ?, ?)`, + ) + .run( + resolverSnapshotId, + resolverRunId, + resolverWorkflowId, + resolverWorkflowProjectId, + resolverSnapshotSha, + NOW, + ); + const resolverWorkflowJob = resolverJobs.getJob(resolverWorkflowJobId); + assert.ok(resolverWorkflowJob); + assert.deepEqual(scopeResolver.resolve(resolverWorkflowJob), { + jobId: resolverWorkflowJobId, + type: "workflow_run", + scope: "project", + projectId: resolverWorkflowProjectId, + }); + assert.equal( + scopeResolver.resolve({ + ...resolverWorkflowJob, + payload: { ...resolverWorkflowPayload, snapshotSha256: "b".repeat(64) }, + })?.scope, + "unresolved", + ); + + const resolverTabularProjectId = createProject(projects, { + name: "Resolver Tabular Project", + }); + const resolverDocumentId = randomUUID(); + const resolverReviewId = randomUUID(); + const resolverColumnId = randomUUID(); + const resolverCellId = randomUUID(); + const resolverTabularJobId = randomUUID(); + const resolverVersionId = randomUUID(); + const resolverBlobId = randomUUID(); + database + .prepare( + `INSERT INTO documents ( + id, project_id, title, filename, mime_type, size_bytes, + created_at, updated_at + ) VALUES (?, ?, 'Resolver document', 'resolver.txt', 'text/plain', + 1, ?, ?)`, + ) + .run(resolverDocumentId, resolverTabularProjectId, NOW, NOW); + database + .prepare( + `INSERT INTO tabular_reviews ( + id, project_id, title, status, created_at, updated_at + ) VALUES (?, ?, 'Resolver review', 'running', ?, ?)`, + ) + .run(resolverReviewId, resolverTabularProjectId, NOW, NOW); + database + .prepare( + `INSERT INTO tabular_review_columns ( + id, review_id, key, title, output_type, ordinal, + created_at, updated_at + ) VALUES (?, ?, 'resolver', 'Resolver', 'text', 0, ?, ?)`, + ) + .run(resolverColumnId, resolverReviewId, NOW, NOW); + database + .prepare( + `INSERT INTO tabular_review_documents ( + review_id, document_id, ordinal, created_at + ) VALUES (?, ?, 0, ?)`, + ) + .run(resolverReviewId, resolverDocumentId, NOW); + const resolverTabularPayload = { + schema: "vera-tabular-cell-job-v1", + reviewId: resolverReviewId, + projectId: resolverTabularProjectId, + cellId: resolverCellId, + generationId: resolverTabularJobId, + document: { + documentId: resolverDocumentId, + versionId: resolverVersionId, + blobRecordId: resolverBlobId, + sourceContentSha256: "c".repeat(64), + textSha256: "d".repeat(64), + textBytes: 1, + }, + column: { + columnId: resolverColumnId, + revisionSha256: "e".repeat(64), + }, + model: { profileId: resolverModelId, executionRevision: 0 }, + reviewRevisionSha256: "f".repeat(64), + generation: 1, + }; + insertDurableRunningJob(database, { + id: resolverTabularJobId, + type: "tabular_cell", + resourceType: "tabular_cell", + resourceId: resolverCellId, + payload: resolverTabularPayload, + }); + database + .prepare( + `INSERT INTO tabular_cells ( + id, review_id, document_id, column_id, output_type, status, + job_id, attempt, created_at, updated_at + ) VALUES (?, ?, ?, ?, 'text', 'running', ?, 1, ?, ?)`, + ) + .run( + resolverCellId, + resolverReviewId, + resolverDocumentId, + resolverColumnId, + resolverTabularJobId, + NOW, + NOW, + ); + const resolverTabularJob = resolverJobs.getJob(resolverTabularJobId); + assert.ok(resolverTabularJob); + const frozenTabularScope = scopeResolver.resolve(resolverTabularJob); + assert.deepEqual(frozenTabularScope, { + jobId: resolverTabularJobId, + type: "tabular_cell", + scope: "project", + projectId: resolverTabularProjectId, + }); + assert.equal( + scopeResolver.resolve({ + ...resolverTabularJob, + payload: { ...resolverTabularPayload, projectId: randomUUID() }, + })?.scope, + "unresolved", + ); + + const frozenTabularController = new AbortController(); + assert.ok(frozenTabularScope); + abortRegistry.register( + resolverTabularJobId, + frozenTabularController, + frozenTabularScope, + ); + database + .prepare( + `UPDATE jobs + SET status = 'cancelled', cancel_requested_at = ?, + cancellation_reason = 'Resolver audit cancellation', + completed_at = ?, lease_owner = NULL, + lease_expires_at = NULL, updated_at = ? + WHERE id = ?`, + ) + .run(NOW, NOW, NOW, resolverTabularJobId); + database + .prepare("DELETE FROM tabular_cells WHERE id = ?") + .run(resolverCellId); + database + .prepare("DELETE FROM tabular_reviews WHERE id = ?") + .run(resolverReviewId); + database + .prepare("DELETE FROM documents WHERE id = ?") + .run(resolverDocumentId); + assert.equal( + scopeResolver.resolve(resolverTabularJob)?.scope, + "unresolved", + ); + assert.equal( + projects.hasBlockingInferenceJobs(resolverTabularProjectId, []), + false, + ); + expectApiError( + () => + service.createProjectMatterProfile( + localContext, + resolverTabularProjectId, + { workspaceType: "research" }, + ), + 409, + "CONFLICT", + ); + assert.equal(frozenTabularController.signal.aborted, false); + abortRegistry.unregister(resolverTabularJobId, frozenTabularController); + assert.equal( + service.createProjectMatterProfile( + localContext, + resolverTabularProjectId, + { workspaceType: "research" }, + ).profileState, + "ready", + ); + + // The generic Job runtime resolves synchronously before registration and + // keeps the frozen scope until its controller-identity finally block runs. + const runtimeScopePath = path.join(root, "scope-runtime.db"); + const runtimeScopeDatabase = new WorkspaceDatabase(runtimeScopePath); + try { + const runtimeProjects = new ProjectsRepository(runtimeScopeDatabase); + const runtimeProjectId = createProject(runtimeProjects, { + name: "Runtime frozen scope Project", + }); + const runtimeModelId = seedResolverModelProfile(runtimeScopeDatabase); + const runtimeChatId = randomUUID(); + const runtimePromptId = randomUUID(); + const runtimeOutputId = randomUUID(); + const runtimeJobId = randomUUID(); + runtimeScopeDatabase + .prepare( + `INSERT INTO chats ( + id, project_id, scope, title, created_at, updated_at + ) VALUES (?, ?, 'project', 'Runtime frozen scope', ?, ?)`, + ) + .run(runtimeChatId, runtimeProjectId, NOW, NOW); + runtimeScopeDatabase + .prepare( + `INSERT INTO chat_messages ( + id, chat_id, sequence, role, content, status, + created_at, updated_at, completed_at + ) VALUES (?, ?, 0, 'user', 'Runtime prompt', 'complete', ?, ?, ?)`, + ) + .run(runtimePromptId, runtimeChatId, NOW, NOW, NOW); + const runtimePayload = { + schema: "vera-assistant-generation-v1", + chatId: runtimeChatId, + projectId: runtimeProjectId, + promptMessageId: runtimePromptId, + outputMessageId: runtimeOutputId, + modelProfileId: runtimeModelId, + documents: [], + retrieval: { currentVersionOnly: true, limit: 10 }, + }; + insertDurableRunningJob(runtimeScopeDatabase, { + id: runtimeJobId, + type: "assistant_generate", + resourceType: "chat", + resourceId: runtimeChatId, + payload: runtimePayload, + }); + runtimeScopeDatabase + .prepare( + `UPDATE jobs + SET status = 'queued', attempt = 0, locked_at = NULL, + started_at = NULL, lease_owner = NULL, + lease_expires_at = NULL, updated_at = ? + WHERE id = ?`, + ) + .run(NOW, runtimeJobId); + runtimeScopeDatabase + .prepare( + `INSERT INTO chat_messages ( + id, chat_id, sequence, role, content, status, model_profile_id, + job_id, created_at, updated_at + ) VALUES (?, ?, 1, 'assistant', '', 'pending', ?, ?, ?, ?)`, + ) + .run( + runtimeOutputId, + runtimeChatId, + runtimeModelId, + runtimeJobId, + NOW, + NOW, + ); + runtimeScopeDatabase + .prepare( + `INSERT INTO assistant_generation_snapshots ( + job_id, chat_id, prompt_message_id, output_message_id, + model_profile_id, current_version_only, retrieval_limit, + created_at + ) VALUES (?, ?, ?, ?, ?, 1, 10, ?)`, + ) + .run( + runtimeJobId, + runtimeChatId, + runtimePromptId, + runtimeOutputId, + runtimeModelId, + NOW, + ); + const runtimeJobsRepository = new WorkspaceJobsRepository( + runtimeScopeDatabase, + ); + const runtimeResolver = new CanonicalProjectInferenceScopeResolver( + new ChatsRepository(runtimeScopeDatabase), + new WorkflowsService( + new WorkflowsRepository(runtimeScopeDatabase), + new WorkspaceJobEnqueuerAdapter( + new WorkspaceJobsService(runtimeJobsRepository), + ), + ), + new TabularRepository(runtimeScopeDatabase), + ); + const runtimeRegistry = new WorkspaceJobAbortRegistry(); + let releaseHandler!: () => void; + const handlerRelease = new Promise((resolve) => { + releaseHandler = resolve; + }); + let markHandlerStarted!: () => void; + const handlerStarted = new Promise((resolve) => { + markHandlerStarted = resolve; + }); + const jobRuntime = new WorkspaceJobRuntime( + runtimeJobsRepository, + { + assistant_generate: async () => { + markHandlerStarted(); + await handlerRelease; + return { ok: true }; + }, + }, + { + abortRegistry: runtimeRegistry, + inferenceScopeResolver: runtimeResolver.resolve, + recoveryMode: "fenced", + allowedJobTypes: ["assistant_generate"], + manageProcessSignals: false, + now: () => new Date(NOW), + }, + ); + await jobRuntime.start(); + const running = jobRuntime.claimAndRun(); + await handlerStarted; + assert.deepEqual(runtimeRegistry.activeInferenceScopes(), [ + { + jobId: runtimeJobId, + type: "assistant_generate", + scope: "project", + projectId: runtimeProjectId, + }, + ]); + runtimeScopeDatabase + .prepare( + `UPDATE jobs + SET status = 'cancelled', cancel_requested_at = ?, + cancellation_reason = 'Runtime scope audit', + completed_at = ?, lease_owner = NULL, + lease_expires_at = NULL, updated_at = ? + WHERE id = ?`, + ) + .run(NOW, NOW, NOW, runtimeJobId); + runtimeScopeDatabase + .prepare("DELETE FROM chats WHERE id = ?") + .run(runtimeChatId); + assert.deepEqual(runtimeRegistry.activeInferenceScopes(), [ + { + jobId: runtimeJobId, + type: "assistant_generate", + scope: "project", + projectId: runtimeProjectId, + }, + ]); + releaseHandler(); + await running; + assert.deepEqual(runtimeRegistry.activeInferenceScopes(), []); + await jobRuntime.stop(); + } finally { + runtimeScopeDatabase.close(); + } + + // Query-order evidence for the TOCTOU boundary. A second connection cannot + // enqueue after the scoped activity read but before profile creation while + // BEGIN IMMEDIATE is held. Its retry after COMMIT reaches the final Matter + // model gate and therefore makes zero provider calls. + const serializedProjectId = createProject(projects, { + name: "Serialized conversion and enqueue", + }); + const serializedChatId = randomUUID(); + const serializedJobId = randomUUID(); + database + .prepare( + `INSERT INTO chats (id, project_id, scope, title, created_at, updated_at) + VALUES (?, ?, 'project', 'Serialized enqueue audit', ?, ?)`, + ) + .run(serializedChatId, serializedProjectId, NOW, NOW); + const enqueueDatabase = new WorkspaceDatabase(databasePath, { + migrate: false, + }); + try { + enqueueDatabase.exec("PRAGMA busy_timeout = 1"); + const transactionEvents: string[] = []; + let enqueueAttempted = false; + let enqueueBlocked = false; + const enqueue = () => + enqueueDatabase + .prepare( + `INSERT INTO jobs ( + id, type, status, resource_type, resource_id, payload_json, + scheduled_at, created_at, updated_at + ) VALUES (?, 'assistant_generate', 'queued', 'chat', ?, '{}', + ?, ?, ?)`, + ) + .run(serializedJobId, serializedChatId, NOW, NOW, NOW); + const transactionDatabase: WorkspaceDatabaseAdapter = { + exec(sql) { + transactionEvents.push(`EXEC ${sql.trim()}`); + return database!.exec(sql); + }, + prepare(sql) { + const compact = sql.replace(/\s+/g, " ").trim(); + transactionEvents.push(`SQL ${compact}`); + if (!enqueueAttempted && compact.includes("FROM jobs j")) { + enqueueAttempted = true; + try { + enqueue(); + } catch { + enqueueBlocked = true; + } + } + return database!.prepare(sql); + }, + }; + const transactionProjects = new ProjectsRepository(transactionDatabase); + const transactionProfiles = new MatterProfileRepository( + transactionDatabase, + ); + const transactionOverview = new MatterOverviewRepository( + transactionDatabase, + ); + const transactionService = new MatterProfileService( + transactionDatabase, + transactionProjects, + transactionProfiles, + transactionOverview, + createProjectInferenceActivityPort(transactionProjects, () => { + transactionEvents.push("SNAPSHOT activeInferenceScopes"); + return abortRegistry.activeInferenceScopes(); + }), + { clock: () => new Date(NOW) }, + ); + transactionService.createProjectMatterProfile( + localContext, + serializedProjectId, + { workspaceType: "general_legal" }, + ); + assert.equal(enqueueAttempted, true); + assert.equal(enqueueBlocked, true); + assert.equal( + database + .prepare("SELECT id FROM jobs WHERE id = ?") + .get(serializedJobId), + undefined, + ); + assert.equal( + transactionEvents.filter((event) => event === "EXEC BEGIN IMMEDIATE") + .length, + 1, + ); + assert.equal( + transactionEvents.filter((event) => event === "EXEC COMMIT").length, + 1, + ); + assert.equal( + transactionEvents.some((event) => event === "EXEC ROLLBACK"), + false, + ); + const eventIndex = (fragment: string) => { + const index = transactionEvents.findIndex((event) => + event.includes(fragment), + ); + assert.notEqual(index, -1, `missing transaction event: ${fragment}`); + return index; + }; + assert.ok( + eventIndex("EXEC BEGIN IMMEDIATE") < + eventIndex("SELECT * FROM projects WHERE id = ?") && + eventIndex("SELECT * FROM projects WHERE id = ?") < + eventIndex("FROM matter_profiles WHERE project_id = ?") && + eventIndex("FROM matter_profiles WHERE project_id = ?") < + eventIndex("SNAPSHOT activeInferenceScopes") && + eventIndex("SNAPSHOT activeInferenceScopes") < + eventIndex("FROM jobs j") && + eventIndex("FROM jobs j") < + eventIndex("INSERT INTO matter_profiles") && + eventIndex("INSERT INTO matter_profiles") < + eventIndex("UPDATE projects SET") && + eventIndex("UPDATE projects SET") < + eventIndex("WITH selected_projects AS") && + eventIndex("WITH selected_projects AS") < eventIndex("EXEC COMMIT"), + ); + + enqueue(); + const inferencePolicy = new MatterInferencePolicyGate(database); + let providerCalls = 0; + expectApiError( + () => { + inferencePolicy.assertProjectModelUse(serializedProjectId); + providerCalls += 1; + }, + 412, + "PRECONDITION_FAILED", + ); + assert.equal(providerCalls, 0); + } finally { + enqueueDatabase.close(); + } + + // Profile creation is explicit, uses the v15 compatibility sentinel, and + // moves time monotonically beyond an offset Project timestamp. + const conversionProjectId = createProject(projects, { + name: "Explicit conversion Project", + now: "2026-07-17T00:00:00.000+08:00", + }); + const converted = service.createProjectMatterProfile( + localContext, + conversionProjectId, + { + workspaceType: "compliance", + clientName: " Explicit Client ", + jurisdiction: " PRC ", + }, + ); + assert.equal(converted.profile?.workspaceType, "compliance"); + assert.equal(converted.profile?.clientName, "Explicit Client"); + assert.equal(converted.profile?.jurisdiction, "PRC"); + assert.equal(converted.profileState, "ready"); + assert.equal(converted.project.updatedAt, "2026-07-16T16:00:00.001Z"); + const convertedStorage = database + .prepare( + `SELECT matter_type, workspace_type + FROM matter_profiles + WHERE project_id = ?`, + ) + .get(conversionProjectId); + assert.equal(convertedStorage?.matter_type, "general"); + assert.equal(convertedStorage?.workspace_type, "compliance"); + expectApiError( + () => + service.createProjectMatterProfile(localContext, conversionProjectId, { + workspaceType: "general_legal", + }), + 409, + "CONFLICT", + ); + + const created = service.createMatter(localContext, { + name: "Alpha Matter", + description: "Created atomically over the Project boundary.", + cmNumber: "MAT-001", + practice: "General commercial", + workspaceType: "dispute", + clientName: "Alpha Client", + jurisdiction: "PRC", + representedRole: "Claimant counsel", + objective: "Obtain a reviewed outcome.", + }); + assert.equal(created.profile?.projectId, created.project.id); + assert.equal(created.profile?.workspaceType, "dispute"); + assert.equal(created.project.cmNumber, "MAT-001"); + assert.equal(created.project.practice, "General commercial"); + const createdStorage = database + .prepare( + `SELECT matter_type, workspace_type + FROM matter_profiles + WHERE project_id = ?`, + ) + .get(created.project.id); + assert.equal(createdStorage?.matter_type, "general"); + assert.equal(createdStorage?.workspace_type, "dispute"); + + seedOverviewCounts(database, created.project.id); + const counted = service.getMatter(localContext, created.project.id); + assert.deepEqual( + { + documents: counted.project.documentCount, + chats: counted.project.chatCount, + tabularReviews: counted.project.tabularReviewCount, + workflows: counted.project.workflowCount, + }, + { documents: 1, chats: 1, tabularReviews: 1, workflows: 1 }, + ); + const countedWire = toMatterViewWire(counted); + assert.equal(countedWire.project.tabular_review_count, 1); + assert.equal("review_count" in countedWire.project, false); + + // One bounded list statement performs the Project left join and every + // count; mapping a page performs no per-item repository reads. + let prepareCount = 0; + const preparedSql: string[] = []; + const countingDatabase: WorkspaceDatabaseAdapter = { + exec: database.exec.bind(database), + prepare(sql) { + prepareCount += 1; + preparedSql.push(sql); + return database!.prepare(sql); + }, + }; + const countingRepository = new MatterOverviewRepository(countingDatabase); + const countedPage = countingRepository.list({ limit: 100 }); + assert.ok(countedPage.items.length >= 4); + assert.equal(prepareCount, 1); + assert.match(preparedSql[0] ?? "", /LEFT JOIN matter_profiles/); + assert.match(preparedSql[0] ?? "", /tabular_review_counts/); + assert.ok( + countedPage.items.some( + (item) => item.project.id === emptyGenericId && item.profile === null, + ), + ); + + const second = service.createMatter(localContext, { + name: "Second Matter", + workspaceType: "research", + clientName: "Second Client", + }); + const firstPage = service.listMatters(localContext, { limit: 1 }); + assert.equal(firstPage.items.length, 1); + assert.ok(firstPage.nextCursor); + const secondPage = service.listMatters(localContext, { + limit: 1, + cursor: firstPage.nextCursor, + }); + assert.equal(secondPage.items.length, 1); + assert.notEqual( + firstPage.items[0].project.id, + secondPage.items[0].project.id, + ); + + projects.update(created.project.id, { + now: "2027-02-01T00:00:00.000+08:00", + }); + const updated = service.updateProjectMatterProfile( + localContext, + created.project.id, + { + workspaceType: "transaction", + jurisdiction: "Singapore", + }, + ); + assert.equal(updated.profile?.workspaceType, "transaction"); + assert.equal(updated.profile?.jurisdiction, "Singapore"); + assert.equal(updated.project.updatedAt, "2027-01-31T16:00:00.001Z"); + assert.equal(updated.profile?.updatedAt, updated.project.updatedAt); + assert.equal( + database + .prepare("SELECT matter_type FROM matter_profiles WHERE project_id = ?") + .get(created.project.id)?.matter_type, + "general", + ); + + // Archived Projects remain readable but cannot create or mutate a profile. + const archivedGenericId = createProject(projects, { + name: "Archived generic Project", + }); + projects.archive(archivedGenericId, "2026-07-16T11:00:00.000Z"); + const archivedGeneric = service.getMatter(localContext, archivedGenericId); + assert.equal(archivedGeneric.profileState, "absent"); + assert.deepEqual(archivedGeneric.capabilities, { + matterProfile: "unavailable", + inference: "unavailable", + review: "unavailable", + drafts: "document_scoped", + }); + expectApiError( + () => + service.createProjectMatterProfile(localContext, archivedGenericId, { + workspaceType: "general_legal", + }), + 409, + "CONFLICT", + ); + projects.archive(legacyProjectId, "2026-07-16T11:00:00.001Z"); + const archivedLegacy = service.getMatter(localContext, legacyProjectId); + assert.equal(archivedLegacy.profileState, "classification_required"); + assert.equal(archivedLegacy.capabilities.matterProfile, "unavailable"); + assert.equal(archivedLegacy.capabilities.inference, "unavailable"); + projects.archive(created.project.id, "2027-02-01T00:00:00.000Z"); + const archivedMatter = service.getMatter(localContext, created.project.id); + assert.equal(archivedMatter.profileState, "ready"); + assert.equal(archivedMatter.capabilities.matterProfile, "unavailable"); + assert.equal(archivedMatter.capabilities.inference, "unavailable"); + expectApiError( + () => + service.updateProjectMatterProfile(localContext, created.project.id, { + objective: "Archived edit must fail", + }), + 409, + "CONFLICT", + ); + + const archivedStatePage = service.listMatters(localContext, { + status: "archived", + limit: 100, + }); + for (const [projectId, profileState] of [ + [archivedGenericId, "absent"], + [legacyProjectId, "classification_required"], + [created.project.id, "ready"], + ] as const) { + const item = archivedStatePage.items.find( + (candidate) => candidate.project.id === projectId, + ); + assert.ok(item); + assert.equal(item.profileState, profileState); + assert.equal(item.capabilities.matterProfile, "unavailable"); + assert.equal(item.capabilities.inference, "unavailable"); + } + + const deletedGenericId = createProject(projects, { + name: "Deleted generic Project", + }); + database + .prepare( + "UPDATE projects SET status = 'deleted', updated_at = ? WHERE id = ?", + ) + .run("2027-02-01T00:00:00.002Z", deletedGenericId); + const deletedGeneric = service.getMatter(localContext, deletedGenericId); + assert.equal(deletedGeneric.profileState, "absent"); + assert.equal(deletedGeneric.capabilities.matterProfile, "unavailable"); + assert.equal(deletedGeneric.capabilities.inference, "unavailable"); + + const deletedMatter = service.createMatter(localContext, { + name: "Deleted classified Matter", + workspaceType: "investigation", + }); + database + .prepare( + "UPDATE projects SET status = 'deleted', updated_at = ? WHERE id = ?", + ) + .run("2027-02-01T00:00:00.003Z", deletedMatter.project.id); + const deletedReady = service.getMatter( + localContext, + deletedMatter.project.id, + ); + assert.equal(deletedReady.profileState, "ready"); + assert.equal(deletedReady.capabilities.matterProfile, "unavailable"); + assert.equal(deletedReady.capabilities.inference, "unavailable"); + expectApiError( + () => + service.updateProjectMatterProfile( + localContext, + deletedMatter.project.id, + { objective: "Deleted edit must fail" }, + ), + 409, + "CONFLICT", + ); + + // A failed Profile insert rolls the injected Project repository write back + // in the same outer transaction; SQLite text remains private. + database.exec(` + CREATE TRIGGER audit_reject_matter_profile + BEFORE INSERT ON matter_profiles + WHEN new.client_name = 'Atomic rejection' + BEGIN + SELECT RAISE(ABORT, '${SECRET} ${PRIVATE_PATH}'); + END; + `); + const beforeAtomic = Number( + database.prepare("SELECT count(*) AS count FROM projects").get()?.count, + ); + const atomicFailure = expectApiError( + () => + service.createMatter(localContext, { + name: "Must roll back", + workspaceType: "general_legal", + clientName: "Atomic rejection", + }), + 500, + "INTERNAL_ERROR", + ); + assertRedacted(atomicFailure.toResponse()); + assert.equal( + Number( + database.prepare("SELECT count(*) AS count FROM projects").get()?.count, + ), + beforeAtomic, + ); + database.exec("DROP TRIGGER audit_reject_matter_profile"); + + const missingClassification = expectApiError( + () => + service.createMatter(localContext, { + name: "Missing classification", + }), + 400, + "VALIDATION_ERROR", + ); + assertRedacted(missingClassification.toResponse()); + const malformed = expectApiError( + () => + service.createMatter(localContext, { + name: "Malformed", + workspaceType: `${SECRET}${PRIVATE_PATH}`, + matterType: "civil_litigation", + }), + 400, + "VALIDATION_ERROR", + ); + assertRedacted(malformed.toResponse()); + assert.ok( + malformed.details?.every((detail) => detail.message === "Invalid value."), + ); + expectApiError( + () => + service.updateProjectMatterProfile( + localContext, + created.project.id, + {}, + ), + 400, + "VALIDATION_ERROR", + ); + + expectApiError( + () => service.listMatters({ principalId: randomUUID() }, { limit: 1 }), + 403, + "FORBIDDEN", + ); + const stopped = new MatterProfileService( + database, + projects, + repository, + overview, + inferenceActivity, + { acceptingRequests: () => false }, + ); + expectApiError( + () => stopped.listMatters(localContext, {}), + 409, + "CONFLICT", + ); + + database.exec("PRAGMA ignore_check_constraints = ON"); + database + .prepare( + "UPDATE matter_profiles SET workspace_type = ? WHERE project_id = ?", + ) + .run(`${SECRET}${PRIVATE_PATH}`, second.project.id); + const corrupt = expectApiError( + () => service.getMatter(localContext, second.project.id), + 500, + "INTERNAL_ERROR", + ); + assertRedacted(corrupt.toResponse()); + database + .prepare( + "UPDATE matter_profiles SET workspace_type = 'research' WHERE project_id = ?", + ) + .run(second.project.id); + database.exec("PRAGMA ignore_check_constraints = OFF"); + + const app = express(); + app.use(express.json({ limit: "64kb", strict: true })); + app.use( + "/api/v1", + createMatterProfileV1Router(service, { + principal: (request: Request) => + typeof request.headers["x-audit-principal"] === "string" + ? request.headers["x-audit-principal"] + : undefined, + }), + ); + server = createServer(app); + await new Promise((resolve) => + server?.listen(0, "127.0.0.1", resolve), + ); + const address = server.address(); + assert.ok(address && typeof address === "object"); + const origin = `http://127.0.0.1:${address.port}`; + + assert.equal( + ( + await requestJson(origin, "/api/v1/matters", { + principal: null, + }) + ).status, + 401, + ); + assert.equal( + ( + await requestJson(origin, "/api/v1/matters", { + principal: randomUUID(), + }) + ).status, + 403, + ); + + const listResponse = await requestJson(origin, "/api/v1/matters?limit=100"); + assert.equal(listResponse.status, 200); + const listWire = MatterViewPageWireSchema.parse(listResponse.body); + assert.ok( + listWire.items.some( + (item) => + item.project.id === emptyGenericId && + item.matter_profile === null && + item.profile_state === "absent" && + item.capabilities.inference === "workspace_compatibility", + ), + ); + + const archivedListResponse = await requestJson( + origin, + "/api/v1/matters?status=archived&limit=100", + ); + assert.equal(archivedListResponse.status, 200); + const archivedListWire = MatterViewPageWireSchema.parse( + archivedListResponse.body, + ); + for (const [projectId, profileState] of [ + [archivedGenericId, "absent"], + [legacyProjectId, "classification_required"], + [created.project.id, "ready"], + ] as const) { + const item = archivedListWire.items.find( + (candidate) => candidate.project.id === projectId, + ); + assert.ok(item); + assert.equal(item.profile_state, profileState); + assert.equal(item.capabilities.matter_profile, "unavailable"); + assert.equal(item.capabilities.inference, "unavailable"); + } + + const detailResponse = await requestJson( + origin, + `/api/v1/matters/${created.project.id}`, + ); + assert.equal(detailResponse.status, 200); + assert.equal( + MatterViewWireSchema.parse(detailResponse.body).project.id, + created.project.id, + ); + + const httpProjectId = createProject(projects, { + name: "HTTP profile Project", + }); + const readAbsent = await requestJson( + origin, + `/api/v1/projects/${httpProjectId}/matter-profile`, + ); + assert.equal(readAbsent.status, 200); + assert.equal( + MatterViewWireSchema.parse(readAbsent.body).profile_state, + "absent", + ); + + const createProfileResponse = await requestJson( + origin, + `/api/v1/projects/${httpProjectId}/matter-profile`, + { + method: "POST", + body: { + workspace_type: "investigation", + client_name: "HTTP Profile Client", + jurisdiction: "PRC", + }, + }, + ); + assert.equal(createProfileResponse.status, 201); + const httpProfile = MatterViewWireSchema.parse(createProfileResponse.body); + assert.equal(httpProfile.matter_profile?.workspace_type, "investigation"); + assertProfileWireKeys(httpProfile.matter_profile); + + const routeCreate = await requestJson(origin, "/api/v1/matters", { + method: "POST", + body: { + name: "HTTP Matter", + cm_number: "HTTP-001", + practice: "Research", + workspace_type: "research", + client_name: "HTTP Client", + }, + }); + assert.equal(routeCreate.status, 201); + const routeMatter = MatterViewWireSchema.parse(routeCreate.body); + assert.equal(routeMatter.matter_profile?.workspace_type, "research"); + assert.equal(routeMatter.project.cm_number, "HTTP-001"); + assertProfileWireKeys(routeMatter.matter_profile); + + projects.unarchive(legacyProjectId, "2027-02-01T00:00:00.004Z"); + const classifyLegacy = await requestJson( + origin, + `/api/v1/projects/${legacyProjectId}/matter-profile`, + { + method: "PATCH", + body: { + workspace_type: "dispute", + jurisdiction: "PRC", + }, + }, + ); + assert.equal(classifyLegacy.status, 200); + const classifiedLegacy = MatterViewWireSchema.parse(classifyLegacy.body); + assert.equal(classifiedLegacy.profile_state, "ready"); + assert.equal(classifiedLegacy.capabilities.matter_profile, "edit"); + assert.equal( + database + .prepare("SELECT matter_type FROM matter_profiles WHERE project_id = ?") + .get(legacyProjectId)?.matter_type, + "civil_litigation", + ); + + const routeMalformed = await requestJson(origin, "/api/v1/matters", { + method: "POST", + body: { + name: "Secret enum", + workspace_type: `${SECRET}${PRIVATE_PATH}`, + court: PRIVATE_PATH, + }, + }); + assert.equal(routeMalformed.status, 400); + assertRedacted(routeMalformed.body); + assert.ok(JSON.stringify(routeMalformed.body).includes("Invalid value.")); + + const emptyPatch = await requestJson( + origin, + `/api/v1/projects/${routeMatter.project.id}/matter-profile`, + { method: "PATCH", body: {} }, + ); + assert.equal(emptyPatch.status, 400); + assertRedacted(emptyPatch.body); + + await closeServer(server); + server = null; + + const cascade = service.createMatter(localContext, { + name: "Cascade Matter", + workspaceType: "general_legal", + }); + database.close(); + database = null; + + const reopened = new WorkspaceDatabase(databasePath); + database = reopened; + const restartedProjects = new ProjectsRepository(reopened); + const restartedRepository = new MatterProfileRepository(reopened); + const restartedOverview = new MatterOverviewRepository(reopened); + const restarted = new MatterProfileService( + reopened, + restartedProjects, + restartedRepository, + restartedOverview, + createProjectInferenceActivityPort(restartedProjects, () => []), + ); + assert.equal( + restarted.getMatter(localContext, created.project.id).profile + ?.workspaceType, + "transaction", + ); + assert.equal( + restarted.getMatter(localContext, legacyProjectId).profile?.workspaceType, + "dispute", + ); + assert.equal(restartedRepository.readiness().schemaVersion, 16); + reopened + .prepare("DELETE FROM projects WHERE id = ?") + .run(cascade.project.id); + assert.equal( + Number( + reopened + .prepare( + "SELECT count(*) AS count FROM matter_profiles WHERE project_id = ?", + ) + .get(cascade.project.id)?.count, + ), + 0, + ); + + const indexSource = readFileSync( + path.join(process.cwd(), "src", "matter", "profile", "index.ts"), + "utf8", + ); + assert.equal(indexSource.includes("export *"), false); + assert.equal( + indexSource.includes("export { MatterProfileRepository"), + false, + ); + assert.equal(indexSource.includes("export { MatterProfileService"), false); + const repositorySource = readFileSync( + path.join(process.cwd(), "src", "matter", "profile", "repository.ts"), + "utf8", + ); + const overviewSource = readFileSync( + path.join( + process.cwd(), + "src", + "matter", + "profile", + "overviewRepository.ts", + ), + "utf8", + ); + const serviceSource = readFileSync( + path.join(process.cwd(), "src", "matter", "profile", "service.ts"), + "utf8", + ); + assert.equal(repositorySource.includes("INSERT INTO projects"), false); + assert.equal(repositorySource.includes("UPDATE projects"), false); + assert.equal(overviewSource.includes("INSERT INTO matter_profiles"), false); + assert.equal(overviewSource.includes("UPDATE matter_profiles"), false); + assert.equal(overviewSource.includes("INSERT INTO projects"), false); + assert.equal(overviewSource.includes("UPDATE projects"), false); + for (const ownerSource of [repositorySource, overviewSource]) { + assert.equal(ownerSource.includes("BEGIN IMMEDIATE"), false); + assert.equal(ownerSource.includes("COMMIT"), false); + assert.equal(ownerSource.includes("ROLLBACK"), false); + } + assert.equal( + serviceSource.match(/database\.exec\("BEGIN IMMEDIATE"\)/g)?.length, + 1, + "Matter service is the sole write-transaction coordinator", + ); + for (const filename of [ + "contracts.ts", + "repository.ts", + "overviewRepository.ts", + "service.ts", + "router.ts", + "index.ts", + ]) { + const source = readFileSync( + path.join(process.cwd(), "src", "matter", "profile", filename), + "utf8", + ); + assert.equal(source.includes("lib/aletheia"), false); + assert.equal(source.includes("process.env"), false); + assert.equal(source.includes("Keychain"), false); + } + + console.log( + JSON.stringify({ + ok: true, + suite: "vera-matter-profile-module-v16", + checks: [ + "v15 null classification is explicit and old litigation fields stay private", + "new writers require WorkspaceType and persist the general compatibility sentinel", + "generic Projects remain absent with Workspace-compatible inference", + "single-query pagination, counts, left join and no N+1", + "separate Profile/Project/Overview owners under one service transaction", + "queued/running and frozen in-flight inference conversion gates", + "overlapping fenced attempts retain independent controller scopes", + "owner deletion cannot erase registered handler Project scope", + "serialized enqueue retries stop at the final model policy gate", + "archived/deleted capabilities, write gates and monotonic timestamps", + "strict authentication, local principal, lifecycle and redaction", + "truthful v16 readiness and fail-closed Matter inference capability", + "restart persistence and Project-delete cascade", + "all six Matter/Profile routes and narrow module exports", + ], + }), + ); + } finally { + await closeServer(server); + database?.close(); + if (originalEncryption === undefined) { + delete process.env.ALETHEIA_DATABASE_ENCRYPTION; + } else { + process.env.ALETHEIA_DATABASE_ENCRYPTION = originalEncryption; + } + rmSync(root, { recursive: true, force: true }); + } +} + +void main().catch((error) => { + console.error("Vera Matter Profile module audit failed."); + if (error instanceof WorkspaceApiError) { + console.error(`${error.code}: ${error.message}`); + } else if (error instanceof Error) { + console.error(error.message); + } + process.exitCode = 1; +}); diff --git a/backend/src/scripts/veraWorkspaceApplicationAudit.ts b/backend/src/scripts/veraWorkspaceApplicationAudit.ts index 0974079..a9cbf62 100644 --- a/backend/src/scripts/veraWorkspaceApplicationAudit.ts +++ b/backend/src/scripts/veraWorkspaceApplicationAudit.ts @@ -215,6 +215,7 @@ function fakeRuntime( startError?: Error; health?: ReturnType; onListProjects?: () => void; + matterProfiles?: VeraWorkspaceRuntime["matterProfiles"]; } = {}, ): VeraWorkspaceRuntime { const base = { @@ -263,6 +264,9 @@ function fakeRuntime( }, ), workflowCrud: fakeWorkflowCrud(), + ...(options.matterProfiles + ? { matterProfiles: options.matterProfiles } + : {}), }; return new Proxy(base, { get(target, property, receiver) { @@ -528,6 +532,101 @@ async function auditApplicationSurface(): Promise { }); }); + let matterListCalls = 0; + let matterCreateCalls = 0; + const matterRouter = Router(); + matterRouter.get("/matters", (_request, response) => { + matterListCalls += 1; + response.json({ items: [], next_cursor: null }); + }); + matterRouter.post("/matters", (_request, response) => { + matterCreateCalls += 1; + response.status(201).json({ created: true }); + }); + const matterProfiles = { + createRouter: () => matterRouter, + health: () => ({ + status: "ready" as const, + schemaVersion: 16 as const, + inferencePolicy: "gate_closed" as const, + internalPath: "/private/matter.db", + }), + } as NonNullable; + const matterToken = "m".repeat(64); + const matterApp = createVeraApplication({ + runtime: fakeRuntime({ matterProfiles }), + env: testEnvironment({ + ALETHEIA_AUTH_MODE: "private_token", + ALETHEIA_PRIVATE_AUTH_TOKEN: matterToken, + }), + auditAnchorStatus: () => ({ enabled: false, healthy: true }), + }); + await withHttpServer(matterApp, async (baseUrl) => { + const unauthenticated = await fetch(`${baseUrl}/api/v1/matters`); + assert.equal(unauthenticated.status, 401); + assert.equal(matterListCalls, 0, "Matter router must run after auth"); + const list = await fetch(`${baseUrl}/api/v1/matters`, { + headers: { authorization: `Bearer ${matterToken}` }, + }); + assert.equal(list.status, 200); + assert.equal(matterListCalls, 1, "Matter route must dispatch exactly once"); + assertWorkspaceNoStore(list); + const health = await fetch(`${baseUrl}/health`); + assert.equal(health.status, 200); + const payload = (await health.json()) as { + vera: { matter: Record }; + }; + assert.deepEqual(payload.vera.matter, { + status: "ready", + schemaVersion: 16, + inferencePolicy: "gate_closed", + }); + }); + + const blockedMatterApp = createVeraApplication({ + runtime: fakeRuntime({ matterProfiles }), + env: testEnvironment(), + auditAnchorStatus: () => ({ enabled: true, healthy: false }), + auditWriteBlocked: () => true, + }); + await withHttpServer(blockedMatterApp, async (baseUrl) => { + const blocked = await fetch(`${baseUrl}/api/v1/matters`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + assert.equal(blocked.status, 503); + assert.equal(matterCreateCalls, 0, "audit guard must precede Matter routes"); + const readable = await fetch(`${baseUrl}/api/v1/matters`); + assert.equal(readable.status, 200); + assert.equal(matterListCalls, 2); + }); + + const unavailableMatterApp = createVeraApplication({ + runtime: fakeRuntime({ + matterProfiles: { + createRouter: () => matterRouter, + health: () => { + throw new Error("/private/matter.db secret"); + }, + } as NonNullable, + }), + env: testEnvironment(), + auditAnchorStatus: () => ({ enabled: false, healthy: true }), + }); + await withHttpServer(unavailableMatterApp, async (baseUrl) => { + const health = await fetch(`${baseUrl}/health`); + assert.equal(health.status, 503); + const text = await health.text(); + assert(!text.includes("matter.db")); + assert(!text.includes("secret")); + assert.equal( + (JSON.parse(text) as { vera: { matter: { status: string } } }).vera + .matter.status, + "unavailable", + ); + }); + let enabledLegacyFactoryCalls = 0; const legacyProbeRouter = Router(); legacyProbeRouter.get("/enabled-probe", (_request, response) => { diff --git a/backend/src/scripts/veraWorkspaceAssistantExecutionAudit.ts b/backend/src/scripts/veraWorkspaceAssistantExecutionAudit.ts index 2553b40..502639f 100644 --- a/backend/src/scripts/veraWorkspaceAssistantExecutionAudit.ts +++ b/backend/src/scripts/veraWorkspaceAssistantExecutionAudit.ts @@ -577,7 +577,9 @@ function abortError() { return error; } -function runtimeAuditModel(): AssistantModelPort { +function runtimeAuditModel( + onRunTurn?: (prompt: string) => void | Promise, +): AssistantModelPort { return { async registeredCapabilities() { return { @@ -591,6 +593,7 @@ function runtimeAuditModel(): AssistantModelPort { const prompt = [...input.messages].reverse().find((message) => message.role === "user") ?.content ?? ""; + await onRunTurn?.(prompt); if (prompt.includes("[FAIL]")) { throw Object.assign(new Error("raw provider body"), { code: "assistant_model_failed", @@ -650,6 +653,30 @@ async function waitForStatus( throw new Error(`Assistant job ${jobId} did not reach the expected status.`); } +async function within(promise: Promise, message: string): Promise { + let timeout: ReturnType | null = null; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error(message)), 10_000); + }), + ]); + } finally { + if (timeout !== null) clearTimeout(timeout); + } +} + +function deferredSignal() { + let resolveSignal: () => void = () => { + throw new Error("Deferred signal was not initialized."); + }; + const promise = new Promise((resolve) => { + resolveSignal = resolve; + }); + return { promise, resolve: resolveSignal }; +} + async function replay(baseUrl: string, jobId: string, token: string) { const response = await fetch( `${baseUrl}/api/v1/assistant/jobs/${jobId}/events?cursor=0&limit=100`, @@ -671,10 +698,25 @@ async function auditRuntimePumpAndRoutes(root: string) { const profiles = new ModelProfilesRepository(database); const tests = new ModelConnectionTestsRepository(database); createProfile(profiles, tests, PROFILE_ID, true); + let modelTurnCount = 0; + const conversionRaceEntered = deferredSignal(); + const conversionRaceRelease = deferredSignal(); + let conversionRaceReleased = false; + const releaseConversionRace = () => { + if (conversionRaceReleased) return; + conversionRaceReleased = true; + conversionRaceRelease.resolve(); + }; const runtime = new WorkspaceRuntime({ dataDir: path.join(root, "runtime-data"), database, - assistantModel: runtimeAuditModel(), + assistantModel: runtimeAuditModel(async (prompt) => { + modelTurnCount += 1; + if (prompt.includes("[CONVERSION-RACE]")) { + conversionRaceEntered.resolve(); + await conversionRaceRelease.promise; + } + }), // The execution audit does not create blobs. Keep the production runtime's // existing empty-store test seam instead of weakening encryption policy. blobs: { listStagedDeletesSync: () => [] } as never, @@ -828,7 +870,234 @@ async function auditRuntimePumpAndRoutes(root: string) { 1, "pump cancellation never writes a second terminal after Assistant cancellation commit", ); + + const genericProjectResponse = await fetch( + `${server.baseUrl}/api/v1/projects`, + { + method: "POST", + headers, + body: JSON.stringify({ name: "Generic compatibility Project" }), + }, + ); + assert.equal(genericProjectResponse.status, 201); + const genericProjectId = String( + ((await genericProjectResponse.json()) as { id: unknown }).id, + ); + const genericChatResponse = await fetch( + `${server.baseUrl}/api/v1/chat/create`, + { + method: "POST", + headers, + body: JSON.stringify({ + title: "Generic Project compatibility", + project_id: genericProjectId, + model_profile_id: PROFILE_ID, + }), + }, + ); + assert.equal(genericChatResponse.status, 201); + const genericChatId = String( + ((await genericChatResponse.json()) as { id: unknown }).id, + ); + const genericTurnCount = modelTurnCount; + const genericSubmit = await fetch(`${server.baseUrl}/api/v1/chat`, { + method: "POST", + headers, + body: JSON.stringify({ + chat_id: genericChatId, + model_profile_id: PROFILE_ID, + messages: [{ role: "user", content: "Generic Project remains compatible." }], + }), + }); + assert.equal(genericSubmit.status, 202); + const genericJobId = String( + ((await genericSubmit.json()) as { job_id: unknown }).job_id, + ); + await waitForStatus( + server.baseUrl, + genericJobId, + token, + (status) => status === "complete", + ); + assert.equal(modelTurnCount, genericTurnCount + 1); + + const raceTurnCount = modelTurnCount; + const raceSubmit = await fetch(`${server.baseUrl}/api/v1/chat`, { + method: "POST", + headers, + body: JSON.stringify({ + chat_id: genericChatId, + model_profile_id: PROFILE_ID, + messages: [ + { + role: "user", + content: "[CONVERSION-RACE] Hold the provider boundary.", + }, + ], + }), + }); + assert.equal(raceSubmit.status, 202); + const raceJobId = String( + ((await raceSubmit.json()) as { job_id: unknown }).job_id, + ); + await within( + conversionRaceEntered.promise, + "Assistant provider boundary was not entered.", + ); + assert.equal( + modelTurnCount, + raceTurnCount + 1, + "the conversion race is held inside an active provider turn", + ); + + const blockedConversion = await fetch( + `${server.baseUrl}/api/v1/projects/${genericProjectId}/matter-profile`, + { + method: "POST", + headers, + body: JSON.stringify({ workspace_type: "general_legal" }), + }, + ); + assert.equal( + blockedConversion.status, + 409, + "conversion must reject while an inference job is already running", + ); + const blockedConversionBody = await blockedConversion.text(); + assert.equal(blockedConversionBody.includes("raw provider body"), false); + + const stillGeneric = await fetch( + `${server.baseUrl}/api/v1/projects/${genericProjectId}/matter-profile`, + { headers }, + ); + assert.equal(stillGeneric.status, 200); + const stillGenericBody = (await stillGeneric.json()) as { + matter_profile: unknown; + profile_state: string; + }; + assert.equal(stillGenericBody.matter_profile, null); + assert.equal(stillGenericBody.profile_state, "absent"); + + releaseConversionRace(); + await waitForStatus( + server.baseUrl, + raceJobId, + token, + (status) => status === "complete", + ); + + const completedConversion = await fetch( + `${server.baseUrl}/api/v1/projects/${genericProjectId}/matter-profile`, + { + method: "POST", + headers, + body: JSON.stringify({ workspace_type: "general_legal" }), + }, + ); + assert.equal(completedConversion.status, 201); + const convertedMatter = (await completedConversion.json()) as { + profile_state: string; + capabilities: { inference: string }; + }; + assert.equal(convertedMatter.profile_state, "ready"); + assert.equal(convertedMatter.capabilities.inference, "policy_gate_closed"); + + const beforeConvertedSubmit = modelTurnCount; + const convertedSubmit = await fetch(`${server.baseUrl}/api/v1/chat`, { + method: "POST", + headers, + body: JSON.stringify({ + chat_id: genericChatId, + model_profile_id: PROFILE_ID, + messages: [ + { + role: "user", + content: "Converted Matter must now fail closed.", + }, + ], + }), + }); + assert.equal(convertedSubmit.status, 202); + const convertedJobId = String( + ((await convertedSubmit.json()) as { job_id: unknown }).job_id, + ); + await waitForStatus( + server.baseUrl, + convertedJobId, + token, + (status) => status === "failed", + ); + assert.equal( + modelTurnCount, + beforeConvertedSubmit, + "a converted Matter must make zero further provider turns", + ); + + const matterResponse = await fetch(`${server.baseUrl}/api/v1/matters`, { + method: "POST", + headers, + body: JSON.stringify({ + name: "Policy-gated legal Matter", + workspace_type: "general_legal", + }), + }); + assert.equal(matterResponse.status, 201); + const matter = (await matterResponse.json()) as { + project: { id: string }; + profile_state: string; + capabilities: { inference: string }; + }; + assert.equal(matter.profile_state, "ready"); + assert.equal(matter.capabilities.inference, "policy_gate_closed"); + const matterChatResponse = await fetch( + `${server.baseUrl}/api/v1/chat/create`, + { + method: "POST", + headers, + body: JSON.stringify({ + title: "Matter policy gate", + project_id: matter.project.id, + model_profile_id: PROFILE_ID, + }), + }, + ); + assert.equal(matterChatResponse.status, 201); + const matterChatId = String( + ((await matterChatResponse.json()) as { id: unknown }).id, + ); + const beforeClosedMatter = modelTurnCount; + const matterSubmit = await fetch(`${server.baseUrl}/api/v1/chat`, { + method: "POST", + headers, + body: JSON.stringify({ + chat_id: matterChatId, + model_profile_id: PROFILE_ID, + messages: [{ role: "user", content: "This must fail closed." }], + }), + }); + assert.equal(matterSubmit.status, 202); + const matterJobId = String( + ((await matterSubmit.json()) as { job_id: unknown }).job_id, + ); + await waitForStatus( + server.baseUrl, + matterJobId, + token, + (status) => status === "failed", + ); + assert.equal( + modelTurnCount, + beforeClosedMatter, + "a Matter without Gate 3 policy must make zero provider turns", + ); + const matterReplay = await replay(server.baseUrl, matterJobId, token); + assert.equal(matterReplay.terminal, true); + assert.equal( + matterReplay.events.some((event) => event.event.type === "content_delta"), + false, + ); } finally { + releaseConversionRace(); await server.close(); await runtime.stop(); } @@ -845,7 +1114,7 @@ async function main() { adapterDatabase = null; await auditRuntimePumpAndRoutes(root); console.log( - "Vera Assistant execution audit passed (v9-ready provider adapter, bounded snapshot tools, abort/error validation, durable pump, route auth, and single terminal semantics).", + "Vera Assistant execution audit passed (provider adapter, bounded snapshot tools, Matter policy fail-closed, generic compatibility, durable pump, route auth, and single terminal semantics).", ); } finally { adapterDatabase?.close(); diff --git a/backend/src/veraApplication.ts b/backend/src/veraApplication.ts index 3f7d1c0..83fc581 100644 --- a/backend/src/veraApplication.ts +++ b/backend/src/veraApplication.ts @@ -57,6 +57,7 @@ import { createWorkspaceRuntime, type WorkspaceRuntimeHealth, } from "./lib/workspace/runtime"; +import type { MatterProfileModule } from "./matter/profile"; const LOOPBACK_HOST = "127.0.0.1"; const DEFAULT_PORT = 3001; @@ -86,6 +87,10 @@ export interface VeraWorkspaceRuntime readonly modelSettings: WorkspaceModelSettingsRuntimePort; readonly chats: WorkspaceChatsV1Port; readonly tabular: WorkspaceTabularV1RuntimePort; + readonly matterProfiles?: Pick< + MatterProfileModule, + "createRouter" | "health" + >; assistantGenerationAvailable(): boolean; tabularGenerationAvailable(): boolean; start(): Promise; @@ -93,6 +98,41 @@ export interface VeraWorkspaceRuntime health(): WorkspaceRuntimeHealth; } +function configuredMatterRuntime(runtime: VeraWorkspaceRuntime) { + if (!("matterProfiles" in runtime)) return null; + const candidate = runtime.matterProfiles; + return candidate && + typeof candidate.createRouter === "function" && + typeof candidate.health === "function" + ? candidate + : null; +} + +function matterHealth(runtime: VeraWorkspaceRuntime): Record { + const matter = configuredMatterRuntime(runtime); + if (!matter) return { status: "not_configured" }; + try { + const health = matter.health(); + if ( + health && + typeof health === "object" && + !Array.isArray(health) && + health.status === "ready" && + health.schemaVersion === 16 && + health.inferencePolicy === "gate_closed" + ) { + return { + status: "ready", + schemaVersion: 16, + inferencePolicy: "gate_closed", + }; + } + return { status: "unavailable" }; + } catch { + return { status: "unavailable" }; + } +} + export type VeraListeningServer = Pick & { address?: Server["address"]; closeAllConnections?: () => void; @@ -512,9 +552,9 @@ export function createVeraApplication( } // Workspace API composition is intentionally singular: authenticate before - // the audit mutation guard, then place the fixed Mike workflow namespace - // before the broader v1 router. This prevents a generic :id route from - // ever consuming /workflows while keeping one /api/v1 security boundary. + // the audit mutation guard, then place fixed Workflow and Matter namespaces + // before the broader v1 router. This prevents generic :id routes from + // consuming bounded module paths while keeping one /api/v1 boundary. const workspaceApi = Router(); workspaceApi.use(createWorkspaceAuthMiddleware(env)); workspaceApi.use(mutationGuard); @@ -556,6 +596,10 @@ export function createVeraApplication( requireAuthentication: true, }), ); + const matterRuntime = configuredMatterRuntime(options.runtime); + if (matterRuntime) { + workspaceApi.use(matterRuntime.createRouter()); + } workspaceApi.use( createWorkspaceV1Router(options.runtime, { requireAuthentication: true }), ); @@ -580,8 +624,12 @@ export function createVeraApplication( const isLegacyRuntimeConfigured = legacyRuntimeIsEnabled && options.legacyRuntimeConfigured?.() === true; const draining = isDraining() || workspace.draining; + const matter = matterHealth(options.runtime); const healthy = - workspace.started && !draining && (!audit.enabled || audit.healthy); + workspace.started && + !draining && + matter.status !== "unavailable" && + (!audit.enabled || audit.healthy); response.status(healthy ? 200 : 503).json({ ok: healthy, vera: { @@ -599,7 +647,7 @@ export function createVeraApplication( healthy: audit.healthy, protectionActive: audit.protection_active === true, }, - matter: { status: "not_configured" }, + matter, conversation: { status: "not_configured" }, legacy: legacyHealth(isLegacyRuntimeConfigured), }, diff --git a/backend/tsconfig.build.json b/backend/tsconfig.build.json index 6bfbd1d..e4f545a 100644 --- a/backend/tsconfig.build.json +++ b/backend/tsconfig.build.json @@ -9,7 +9,9 @@ "src/desktopMigrate.ts", "src/desktopBackup.ts", "src/lib/workspace/**/*.ts", + "src/matter/**/*.ts", "src/scripts/desktopBackupAudit.ts", + "src/scripts/veraMatter*.ts", "src/scripts/veraWorkspace*.ts" ], "exclude": ["node_modules", "dist"] diff --git a/docs/convergence/phase-2b-matter-profile-api.md b/docs/convergence/phase-2b-matter-profile-api.md new file mode 100644 index 0000000..9724941 --- /dev/null +++ b/docs/convergence/phase-2b-matter-profile-api.md @@ -0,0 +1,216 @@ +# Gate 1 — Matter Profile API + +Date: 2026-07-16 + +## Completed + +Vera exposes Matter as an additive legal projection over the existing Project +ownership boundary. It reuses the canonical `ProjectsRepository`, the existing +Workspace database and the same database transaction boundary; it creates no +second Project, document, chat, workflow, tabular or job implementation. + +Persistence ownership is explicit: `MatterProfileRepository` owns only Profile +rows, `ProjectsRepository` remains the canonical Project owner, and +`MatterOverviewRepository` owns only the read projection. `MatterProfileService` +is the sole coordinator that opens Matter write transactions. All three owners +share the exact injected database adapter and never nest a transaction. + +Generic Projects remain first-class and are returned explicitly with +`matter_profile: null` and `profile_state: absent`. A preserved v15 profile with +no v16 classification is returned with `workspace_type: null` and +`profile_state: classification_required`; Vera never guesses a classification. + +## Authoritative contract + +`WorkspaceType` is the only public Matter classification: + +```text +general_legal | transaction | dispute | investigation | compliance | research +``` + +New Matter and Profile creation requires `workspace_type`. A PATCH can classify +a preserved v15 row whose value is null. New writers keep the v15 NOT NULL +`matter_type` column as private compatibility storage and always write the +non-semantic sentinel `general`; v15 litigation-specific fields are never +projected to the public API. + +The complete public Profile payload is intentionally narrow: + +```text +project_id +workspace_type # nullable only for a preserved pre-v16 row +client_name +jurisdiction +represented_role +objective +created_at +updated_at +``` + +Project wire compatibility remains unchanged for `cm_number`, `practice` and +`status`. Matter counts use the unambiguous name `tabular_review_count`, not +`review_count`. + +## API + +The authenticated `/api/v1` surface is: + +```text +GET /matters +POST /matters +GET /matters/:projectId +GET /projects/:projectId/matter-profile +POST /projects/:projectId/matter-profile +PATCH /projects/:projectId/matter-profile +``` + +`GET /matters` performs one bounded SQL statement over a selected Project page, +one `LEFT JOIN` to profiles and grouped document/chat/tabular/workflow counts. +It includes generic Projects and performs no per-row profile or count reads. + +Project plus Profile creation and generic-Project conversion each use one +`BEGIN IMMEDIATE` transaction. Project persistence is delegated to the injected +`ProjectsRepository`; Profile and Overview owners contain no copied Project +INSERT/UPDATE SQL and open no transaction or database handle. + +## Truthful state and capabilities + +Each projection reports derived state and currently available capability modes: + +| Profile row | `profile_state` | `matter_profile` | `inference` | +| -------------------------------- | ------------------------- | ---------------- | ------------------------- | +| absent | `absent` | `create` | `workspace_compatibility` | +| v15 row with null classification | `classification_required` | `classify` | `policy_gate_closed` | +| classified profile | `ready` | `edit` | `policy_gate_closed` | + +`profile_state` is derived only from Profile presence and classification. For +an archived or deleted Project it therefore remains `absent`, +`classification_required`, or `ready`, while both operational capabilities are +always truthful and unavailable: + +```json +{ + "matter_profile": "unavailable", + "inference": "unavailable" +} +``` + +Every row also reports: + +```json +{ + "review": "unavailable", + "drafts": "document_scoped" +} +``` + +This does not claim a unified Review Center or Matter-wide Draft model. Existing +Document Studio drafts remain document-scoped. Generic Projects keep current +Workspace inference compatibility; any Project with a Matter Profile is stopped +by the Gate 1 policy boundary until Gate 3 installs the Inference Broker. + +The provider boundary is independent of renderer state. Assistant checks the +Project before every model round, including chats with no attached documents. +Workflow checks every prompt step while leaving non-model retrieval and output +steps usable. Tabular rechecks the same Project policy with the authoritative +current document snapshot immediately before model use. Injected adapters are +wrapped by the same production policy seam, so an old deep link or alternate +local adapter cannot skip it. + +Module health is equally explicit: + +```json +{ + "status": "ready", + "schemaVersion": 16, + "inferencePolicy": "gate_closed" +} +``` + +`ready` means the Profile schema and module are usable. It does not mean Matter +inference policy is configured. + +## Security and lifecycle + +- Every router call requires an authenticated Workspace principal; the service + independently enforces the local single-principal boundary. +- Lifecycle acceptance is checked below HTTP so a direct adapter cannot bypass + startup/drain/close state. +- Profile creation and mutation require an active Project; archived Projects + remain readable, and archived/deleted detail and archived-list responses + report unavailable Profile/inference capabilities without erasing the + derived Profile state. +- Generic-to-Matter conversion first acquires `BEGIN IMMEDIATE`, rechecks the + active Project and absent Profile, then checks the complete canonical Project + job graph for queued/running Assistant, Workflow and Tabular inference. It + returns a safe `409` and never cancels or mutates that work. +- Each claimed inference handler synchronously freezes a narrow Project/global + scope from its verified durable execution contract before registration. + Invalid or missing provenance is `unresolved` and fails closed. The copied + scope stays registered until the handler's controller-identity `finally` + unregister, so cancellation, terminal persistence, or deletion of a chat, + run, review, cell, or document cannot open a conversion race. +- A recovered lease retry never overwrites an older provider call with the + same job id. The registry retains every controller/scope instance, aborts all + attempts for that job, and releases conversion only after every instance has + independently unregistered. +- Enqueue/claim writers serialize with the same SQLite writer lock. A job that + retries after conversion commits sees the new Profile at the final model + policy gate and makes no provider call. +- Profile and Project timestamps move monotonically, including when historical + Project timestamps use an explicit UTC offset. +- Strict bounded contracts reject unknown and former v15 fields. Public errors + redact rejected values, SQL errors, credentials, paths and stacks. +- Profile ownership remains the Project foreign key. Project deletion cascades; + restart needs no repair or inferred backfill. +- Matter/Profile code does not access Keychain and does not import Legacy + Aletheia modules. + +## Migrations + +This API consumes the independently audited v15 Matter foundation and additive +v16 classification migration. It does not edit v1-v15 and performs no silent +v15 backfill. + +## Validation + +Focused coverage includes: + +```text +v15 null classification and private litigation-field compatibility +required WorkspaceType and fixed general sentinel for every new writer +generic Project empty optional fields and explicit absent state +single-query pagination/counts/left join with no N+1 +injected ProjectsRepository and atomic rollback +separate Profile/Project/Overview owners with one non-nested transaction +queued/running Assistant, Workflow and Tabular conversion conflicts +registered terminal handler scope retained across owner deletion and unwind +overlapping fenced attempts retained and cancelled by controller identity +document_parse exclusion, terminal history compatibility and unresolved fail-close +same-connection query ordering and concurrent enqueue retry at final policy gate +archived create/update gates and monotonic timestamps +archived list/detail and deleted detail capability truthfulness +strict authentication, local principal, lifecycle and error redaction +v15-not-ready / v16-ready health contract +restart persistence and Project-delete cascade +all six Matter/Profile routes and narrow production exports +Gate 1 inference policy compatibility/closed behavior +real Assistant runtime: generic Project succeeds; Matter makes zero provider calls +backend TypeScript build +``` + +## Rollback + +Reverting the module and route composition removes the API without deleting +Project or Profile data. The additive v15/v16 tables and migration records must +remain immutable; an older binary still needs the compatibility-build or +verified encrypted-backup rollback path. + +## Remaining blockers + +- The current packaged macOS cross-restart run remains the only unfinished + Gate 1 acceptance item; source, migration and security validation passed. +- Unified Proposal/Review remains Gate 2 and is accurately `unavailable` here. +- Inference Broker, model/source policy resolution and Knowledge remain Gate 3; + Matter inference is intentionally closed until then. +- A real authorized Chinese legal source and Office Add-in remain later gates. From 4482415a2fa1c1d4d343842d8f93b2ca4ad794e7 Mon Sep 17 00:00:00 2001 From: shawliu998 <260819717+shawliu998@users.noreply.github.com> Date: Thu, 16 Jul 2026 03:29:31 +0800 Subject: [PATCH 08/10] feat: add matter workspace navigation --- .../phase-2c-matter-workspace-ui.md | 119 ++++ docs/roadmap_legal_workspace.md | 28 +- frontend/next.config.ts | 9 + frontend/package.json | 3 +- .../src/app/(pages)/matters/[id]/page.tsx | 11 + frontend/src/app/(pages)/matters/page.tsx | 7 + .../app/components/vera-shell/VeraSidebar.tsx | 22 +- frontend/src/app/i18n/messages.ts | 212 +++++- frontend/src/app/lib/veraMatterApi.ts | 636 ++++++++++++++++++ .../matter-overview/MatterNavigation.tsx | 84 +++ .../matter-overview/MatterProfileModal.tsx | 363 ++++++++++ .../MatterWorkspaceOverview.tsx | 282 ++++++++ .../matter-overview/MattersOverview.tsx | 345 ++++++++++ frontend/tests/vera-i18n-brand.spec.ts | 10 +- frontend/tests/vera-matter-workspace.test.ts | 283 ++++++++ .../vera-project-workspace-source.spec.ts | 18 +- frontend/tests/vera-shell-foundation.spec.ts | 18 +- frontend/tests/vera-tabular.test.ts | 7 +- 18 files changed, 2414 insertions(+), 43 deletions(-) create mode 100644 docs/convergence/phase-2c-matter-workspace-ui.md create mode 100644 frontend/src/app/(pages)/matters/[id]/page.tsx create mode 100644 frontend/src/app/(pages)/matters/page.tsx create mode 100644 frontend/src/app/lib/veraMatterApi.ts create mode 100644 frontend/src/features/matter-overview/MatterNavigation.tsx create mode 100644 frontend/src/features/matter-overview/MatterProfileModal.tsx create mode 100644 frontend/src/features/matter-overview/MatterWorkspaceOverview.tsx create mode 100644 frontend/src/features/matter-overview/MattersOverview.tsx create mode 100644 frontend/tests/vera-matter-workspace.test.ts diff --git a/docs/convergence/phase-2c-matter-workspace-ui.md b/docs/convergence/phase-2c-matter-workspace-ui.md new file mode 100644 index 0000000..3d59fa5 --- /dev/null +++ b/docs/convergence/phase-2c-matter-workspace-ui.md @@ -0,0 +1,119 @@ +# Gate 1 — Matter Workspace UI + +Date: 2026-07-16 + +## Completed + +Vera's active renderer now presents one Matter-centric product path while +preserving the existing Project data and compatibility routes. + +The top-level information architecture is exactly: + +```text +Assistant | Matters | Workflows | Review | Settings +``` + +`Review` remains visibly unavailable until Gate 2 has a real Proposal service. +Settings keeps its existing runtime capability gate. The exact `/projects` +list route redirects non-permanently to `/matters`; dynamic Project, Assistant, +Workflow, Tabular and Document Studio routes are not redirected or duplicated. + +The new renderer surfaces are: + +```text +/matters +/matters/:projectId +``` + +The list consumes the real bounded Matter projection, separates Projects with +a Profile from generic Projects, and offers explicit create, convert and +classify actions only when the backend capability permits them. It never +silently converts a Project. Matter creation calls the atomic `POST /matters` +boundary rather than issuing separate Project and Profile requests. + +The detail view presents real Project/Profile metadata and real document, +chat, Workflow and Tabular Review counts. It uses the exact label +`tabular_review_count`; it does not relabel that value as a unified Review +count. It does not invent deadlines, tasks, research results, Drafts or next +actions whose authoritative owner is not yet available. + +Matter navigation is exactly: + +```text +Overview | Documents | Assistant | Review | Workflows | Drafts +``` + +Documents and Workflows preserve the existing Project-scoped destinations. +Assistant is enabled only for generic active Projects whose wire capability is +`workspace_compatibility`; a Matter remains visibly policy-gated until Gate 3. +Review is unavailable until Gate 2. A complete Matter-wide Draft list does not +yet exist, so Drafts is unavailable rather than backed by fixture data; +existing document-scoped Studio deep links remain valid. + +Archived and deleted projections remain readable but advertise +`matter_profile: unavailable` and `inference: unavailable`. Profile mutations +and Assistant entry points are therefore absent or disabled instead of +offering operations that the active-Project backend gate would reject. + +## Reused + +- The existing Next.js renderer, Electron layout and Vera design primitives; +- Mike-derived sidebar, page header, table, modal and Project workspace + components under the repository's existing AGPL/source manifest; +- canonical Project Documents, Assistant and Workflow routes; +- the existing authenticated loopback API client and localized error mapping; +- current Document Studio, Tabular and Project deep links without a second + frontend or compatibility data model. + +No competitor interface, brand asset, prompt, private endpoint or proprietary +workflow was copied. + +## Architecture decisions + +- `veraMatterApi.ts` is the only renderer wire boundary for the new surface. + It rejects unknown keys, unbounded text, malformed identifiers/timestamps, + old v15 litigation fields and inconsistent state/capability combinations. +- The renderer derives no Matter classification. It accepts only the six + public `workspace_type` values and requires an explicit user selection for + every create or classify operation. +- Project lifecycle participates in capability validation. Active and + read-only projections cannot be confused by a stale or malformed response. +- Project owns name, description, Matter number, practice and lifecycle; + Profile owns only workspace type, client, jurisdiction, represented role + and objective. +- Navigation preserves compatibility rather than copying Project, Workflow, + Tabular or Studio implementations into a Matter feature folder. + +## Validation + +Executed from `frontend/`: + +```text +npm run test:p0-client # PASS, 87/87 including Matter 4/4 and shell 14/14 +npm run build # PASS; /matters and /matters/[id] in production output +npm run test:i18n # PASS, 6/6 +npx playwright test --config=tests/vera-project-source.config.ts + # PASS, 22/22 +git diff --check -- frontend + # PASS +``` + +The validation uses the production Next configuration and contains no +Keychain mutation or temporary credential fixture. + +## Remaining blockers + +- Gate 1 backend quiescence, lifecycle and inference-boundary review passed + with no remaining P0/P1 findings. +- The packaged macOS cross-restart gate remains separate from renderer build + validation and is the only unfinished Gate 1 acceptance item. +- Unified Review, Matter-wide Draft inventory, open Proposal counts, Work + Queue, policy configuration and legal research are intentionally absent + until their owning gates land. + +## Next gate + +Complete the Gate 1 packaged-runtime evidence. Gate 2's smallest vertical slice +is then one real Document Studio suggestion projected through the Proposal +Contract into the Review Center, with authoritative server-side accept/reject +and stale/source revalidation. diff --git a/docs/roadmap_legal_workspace.md b/docs/roadmap_legal_workspace.md index cf565b1..67268c7 100644 --- a/docs/roadmap_legal_workspace.md +++ b/docs/roadmap_legal_workspace.md @@ -38,10 +38,10 @@ Three commits predate this revised roadmap: | `edf26827` | Retain unchanged. It provides the required default-off Legacy route/runtime flags and lazy loading. | | `408333d7` | Retain as an additive, already-committed v15 foundation. Its narrow `matter_type` is transitional and must not become the new public product taxonomy. | -Migration v15 is treated as immutable. Gate 1 therefore adds a v16 -classification migration rather than changing the v15 checksum. Uncommitted -Matter API/UI work created against the earlier plan is not product evidence and -must be adapted before it can land. +Migration v15 is treated as immutable. Gate 1's schema slice adds v16 rather +than changing the v15 checksum. The Matter API/UI work was adapted to this +contract, independently security-reviewed, and accepted only with its focused +and retained-product regression evidence. ## 3. Gate sequence @@ -82,14 +82,15 @@ Already implemented by `edf26827`: #### 1B. Matter classification migration -Add v16 without editing v15: +Migration v16 adds the following without editing v15: - `workspace_type`: `general_legal`, `transaction`, `dispute`, `investigation`, `compliance`, or `research`; - bounded optional `jurisdiction`; - existing v15 rows remain readable with an explicit `classification_required` capability until the user selects a value; -- new Matter creation requires `workspace_type`; +- new Matter creation requires `workspace_type` at the database boundary; +- a classified row cannot be changed back to an unclassified row; - no automatic mapping guesses from the older v15 `matter_type` values. Because v15 made `matter_type` required, the v16 compatibility writer stores @@ -319,8 +320,8 @@ license, native packaging, saved-audio recovery, and model-weight reviews. ## 4. Provisional migration order -Only v15 is committed. Later numbers are planning reservations and can be split -before their migration lands: +Migrations v15 and v16 are committed. Versions v17 and later remain planning +reservations and can be split before their migration lands: | Version | Domain | | ------- | ---------------------------------------------------------------------------- | @@ -367,7 +368,10 @@ claims change only after the corresponding real vertical acceptance passes. ## 6. Immediate next slice -After Gate 0 is committed, Gate 1 resumes with v16 and the corrected Matter -public contract. The already-created uncommitted API and UI files are reviewed -line by line and adapted; they are not accepted merely because focused tests -for the earlier contract once passed. +Gate 1's v16 schema, corrected Matter API, interim inference boundary, and +Matter UI slices are implemented and have passed the backend/security review. +The remaining Gate 1 acceptance item is a current packaged macOS cross-restart +run. After that release evidence is recorded, Gate 2 begins with one real +Document Studio suggestion projected through the Proposal Contract into the +Review Center, including authoritative accept/reject and stale/source +revalidation. diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 888a7ff..2fb7a0f 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -23,6 +23,15 @@ const nextConfig: NextConfig = { (process.env.NODE_ENV === "production" ? ".next-build" : ".next"), reactCompiler: true, devIndicators: false, + async redirects() { + return [ + { + source: "/projects", + destination: "/matters", + permanent: false, + }, + ]; + }, async rewrites() { return [ { diff --git a/frontend/package.json b/frontend/package.json index 4a39a29..e364580 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,7 +18,8 @@ "test:ocr-review": "tsx --test tests/vera-ocr-review.test.ts", "test:tabular": "tsx --test tests/vera-tabular.test.ts", "test:models-settings": "tsx --test tests/vera-model-settings.test.ts tests/vera-settings-source.test.ts", - "test:p0-client": "npm run lint && npm run test:assistant && npm run test:workflows && npm run test:tabular && npm run test:models-settings && npm run test:legal-sources && npm run test:studio && npm run test:ocr-review && npm run test:shell-source", + "test:matters": "tsx --test tests/vera-matter-workspace.test.ts", + "test:p0-client": "npm run lint && npm run test:assistant && npm run test:workflows && npm run test:tabular && npm run test:models-settings && npm run test:legal-sources && npm run test:studio && npm run test:ocr-review && npm run test:matters && npm run test:shell-source", "test:desktop-csp-runtime": "node tests/vera-desktop-csp-runtime.mjs", "test:aletheia:ui": "playwright test --config=playwright.config.ts", "test:aletheia:ui:install": "playwright install chromium" diff --git a/frontend/src/app/(pages)/matters/[id]/page.tsx b/frontend/src/app/(pages)/matters/[id]/page.tsx new file mode 100644 index 0000000..9aba206 --- /dev/null +++ b/frontend/src/app/(pages)/matters/[id]/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { MatterWorkspaceOverview } from "@/features/matter-overview/MatterWorkspaceOverview"; + +export default function MatterPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + return ; +} diff --git a/frontend/src/app/(pages)/matters/page.tsx b/frontend/src/app/(pages)/matters/page.tsx new file mode 100644 index 0000000..9f7d515 --- /dev/null +++ b/frontend/src/app/(pages)/matters/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { MattersOverview } from "@/features/matter-overview/MattersOverview"; + +export default function MattersPage() { + return ; +} diff --git a/frontend/src/app/components/vera-shell/VeraSidebar.tsx b/frontend/src/app/components/vera-shell/VeraSidebar.tsx index 9ede1ca..695f185 100644 --- a/frontend/src/app/components/vera-shell/VeraSidebar.tsx +++ b/frontend/src/app/components/vera-shell/VeraSidebar.tsx @@ -6,8 +6,8 @@ import { useEffect, useMemo, useState } from "react"; import { PanelLeft, MessageSquare, - FolderOpen, - Table2, + BriefcaseBusiness, + ClipboardCheck, Library, Settings, ChevronDown, @@ -27,13 +27,9 @@ const NAV_ITEMS = [ labelKey: "nav.assistant", icon: MessageSquare, }, - { href: "/projects", labelKey: "nav.projects", icon: FolderOpen }, - { - href: "/tabular-review", - labelKey: "nav.tabular", - icon: Table2, - }, + { href: "/matters", labelKey: "nav.matters", icon: BriefcaseBusiness }, { href: "/workflows", labelKey: "nav.workflows", icon: Library }, + { href: null, labelKey: "nav.review", icon: ClipboardCheck }, { href: null, labelKey: "nav.settings", icon: Settings }, ] as const; @@ -147,10 +143,16 @@ export function VeraSidebar({ isOpen, onToggle }: VeraSidebarProps) { ? "/settings" : href; const isAvailable = href !== null || resolvedHref !== null; + const isMatterRoute = + labelKey === "nav.matters" && + (pathname === "/projects" || + pathname.startsWith("/projects/")); const isActive = isAvailable && - (pathname === resolvedHref || - pathname.startsWith(resolvedHref + "/")); + (isMatterRoute || + (resolvedHref !== null && + (pathname === resolvedHref || + pathname.startsWith(resolvedHref + "/")))); const label = t(labelKey); return (
diff --git a/frontend/src/app/i18n/messages.ts b/frontend/src/app/i18n/messages.ts index 2830f06..f34e86d 100644 --- a/frontend/src/app/i18n/messages.ts +++ b/frontend/src/app/i18n/messages.ts @@ -37,6 +37,7 @@ const zhCN = { ready: "就绪", saved: "已保存", saving: "正在保存…", + unavailable: "不可用", }, fields: { createdAt: "创建时间", @@ -45,10 +46,10 @@ const zhCN = { }, }, nav: { - projects: "项目", assistant: "助手", + matters: "事项", workflows: "工作流", - tabular: "表格", + review: "复核", settings: "设置", }, projects: { @@ -80,6 +81,106 @@ const zhCN = { action: "永久删除项目", }, }, + matters: { + title: "事项", + subtitle: "在一个本地工作区中组织法律事项、文档、助手与工作流。", + create: "新建事项", + loadMore: "加载更多", + loadingMore: "正在加载…", + fields: { + name: "事项名称", + description: "事项说明", + workspaceType: "事项分类", + clientName: "客户名称", + jurisdiction: "司法辖区", + representedRole: "代理角色", + objective: "事项目标", + matterNumber: "事项编号", + practiceArea: "业务领域", + status: "状态", + action: "操作", + }, + workspaceTypes: { + general_legal: "通用法律事项", + transaction: "交易", + dispute: "争议", + investigation: "调查", + compliance: "合规", + research: "研究", + }, + profileStates: { + absent: "普通项目", + classification_required: "需要分类", + ready: "事项资料就绪", + }, + profile: { + title: "事项资料", + create: "创建事项资料", + edit: "编辑事项资料", + classify: "完成分类", + convert: "转换为事项", + genericProject: "普通项目", + classificationRequired: "需要选择事项分类", + classificationBody: + "这是升级前创建的事项资料。选择宽泛的事项分类后,才能继续配置后续事项策略。", + classificationReadOnlyBody: + "这是升级前创建的事项资料;当前生命周期为只读,因此保留原分类状态且不能修改。", + genericTitle: "这是普通项目", + genericBody: + "现有项目不会被静默转换。创建事项资料后,它才会进入事项策略与后续法律工作区能力。", + genericReadOnlyBody: + "此普通项目当前为只读;它不会被静默转换,也不能在当前生命周期创建事项资料。", + missingBody: "此项目尚未创建事项资料。", + }, + sections: { + matters: "法律事项({count})", + genericProjects: "普通项目({count})", + }, + navigation: { + label: "事项工作区", + overview: "概览", + documents: "文档", + assistant: "助手", + review: "复核", + workflows: "工作流", + drafts: "草稿", + }, + capabilities: { + inferenceClosedTitle: "事项推理策略尚未配置", + inferenceClosed: + "为避免在未确定事项策略时使用模型,此事项的助手入口当前已安全关闭。普通项目的现有兼容行为不受影响。", + readOnlyTitle: "此事项当前只读", + readOnly: + "已归档或删除的事项仍可查看,但不能转换、分类、编辑事项资料或启动助手模型调用。", + readOnlyShort: "只读", + reviewUnavailable: "统一复核将在 Gate 2 接入真实复核队列后开放。", + draftsDocumentScoped: + "草稿目前只存在于具体文档工作室中,尚无事项级草稿列表。", + }, + form: { + selectWorkspaceType: "选择事项分类", + }, + status: { + active: "进行中", + archived: "已归档", + deleted: "已删除", + }, + empty: { + title: "还没有事项或项目", + body: "新建法律事项,或在此显式转换已有普通项目。", + action: "新建第一个事项", + }, + errors: { + loadTitle: "无法加载事项", + }, + detail: { + loading: "正在加载事项…", + unavailable: "无法打开此事项", + back: "返回事项", + activity: "本地内容", + tabularReviews: "Tabular 审阅", + }, + }, documents: { title: "文档", subtitle: "集中管理项目资料及其版本。", @@ -1147,6 +1248,7 @@ const enUS = { ready: "Ready", saved: "Saved", saving: "Saving…", + unavailable: "Unavailable", }, fields: { createdAt: "Created", @@ -1155,10 +1257,10 @@ const enUS = { }, }, nav: { - projects: "Projects", assistant: "Assistant", + matters: "Matters", workflows: "Workflows", - tabular: "Tabular", + review: "Review", settings: "Settings", }, projects: { @@ -1192,6 +1294,108 @@ const enUS = { action: "Delete project permanently", }, }, + matters: { + title: "Matters", + subtitle: + "Organize legal matters, documents, Assistant work, and workflows in one local workspace.", + create: "New matter", + loadMore: "Load more", + loadingMore: "Loading…", + fields: { + name: "Matter name", + description: "Matter description", + workspaceType: "Matter classification", + clientName: "Client name", + jurisdiction: "Jurisdiction", + representedRole: "Represented role", + objective: "Matter objective", + matterNumber: "Matter number", + practiceArea: "Practice area", + status: "Status", + action: "Action", + }, + workspaceTypes: { + general_legal: "General legal", + transaction: "Transaction", + dispute: "Dispute", + investigation: "Investigation", + compliance: "Compliance", + research: "Research", + }, + profileStates: { + absent: "Generic Project", + classification_required: "Classification required", + ready: "Matter Profile ready", + }, + profile: { + title: "Matter Profile", + create: "Create Matter Profile", + edit: "Edit Matter Profile", + classify: "Complete classification", + convert: "Convert to Matter", + genericProject: "Generic Project", + classificationRequired: "Matter classification required", + classificationBody: + "This profile predates broad Matter classification. Choose a classification before configuring later Matter policies.", + classificationReadOnlyBody: + "This profile predates broad Matter classification. Its lifecycle is read-only, so the existing classification state is preserved and cannot be changed.", + genericTitle: "This is a generic Project", + genericBody: + "Existing Projects are never converted silently. Create a Matter Profile to opt into Matter policy and future legal-workspace capabilities.", + genericReadOnlyBody: + "This generic Project is read-only. It will not be converted silently, and a Matter Profile cannot be created in its current lifecycle.", + missingBody: "This Project does not have a Matter Profile.", + }, + sections: { + matters: "Legal Matters ({count})", + genericProjects: "Generic Projects ({count})", + }, + navigation: { + label: "Matter workspace", + overview: "Overview", + documents: "Documents", + assistant: "Assistant", + review: "Review", + workflows: "Workflows", + drafts: "Drafts", + }, + capabilities: { + inferenceClosedTitle: "Matter inference policy is not configured", + inferenceClosed: + "Model use is closed for this Matter until its policy is configured. Existing compatibility behavior for generic Projects is unchanged.", + readOnlyTitle: "This Matter is read-only", + readOnly: + "Archived or deleted Matters remain readable, but cannot be converted, classified, edited, or used to start Assistant model calls.", + readOnlyShort: "Read-only", + reviewUnavailable: + "Unified Review will open in Gate 2 after a real review queue is connected.", + draftsDocumentScoped: + "Drafts currently live only in an individual Document Studio; no Matter-level draft list exists yet.", + }, + form: { + selectWorkspaceType: "Choose a Matter classification", + }, + status: { + active: "Active", + archived: "Archived", + deleted: "Deleted", + }, + empty: { + title: "No Matters or Projects yet", + body: "Create a legal Matter or explicitly convert an existing generic Project here.", + action: "Create your first Matter", + }, + errors: { + loadTitle: "Matters could not be loaded", + }, + detail: { + loading: "Loading Matter…", + unavailable: "This Matter could not be opened", + back: "Back to Matters", + activity: "Local content", + tabularReviews: "Tabular reviews", + }, + }, documents: { title: "Documents", subtitle: "Manage project materials and their versions in one place.", diff --git a/frontend/src/app/lib/veraMatterApi.ts b/frontend/src/app/lib/veraMatterApi.ts new file mode 100644 index 0000000..f162112 --- /dev/null +++ b/frontend/src/app/lib/veraMatterApi.ts @@ -0,0 +1,636 @@ +import { VeraApiError, veraApiRequest } from "./veraApi"; +import { VeraRuntimeConfigurationError, type VeraQuery } from "./veraRuntime"; + +export const VERA_WORKSPACE_TYPES = [ + "general_legal", + "transaction", + "dispute", + "investigation", + "compliance", + "research", +] as const; + +export const VERA_MATTER_PROFILE_STATES = [ + "absent", + "classification_required", + "ready", +] as const; + +export type VeraWorkspaceType = (typeof VERA_WORKSPACE_TYPES)[number]; +export type VeraMatterProfileState = + (typeof VERA_MATTER_PROFILE_STATES)[number]; + +export interface VeraMatterProjectWire { + id: string; + name: string; + description: string | null; + cm_number: string | null; + practice: string | null; + status: "active" | "archived" | "deleted"; + default_model_profile_id: string | null; + created_at: string; + updated_at: string; + archived_at: string | null; + document_count: number; + chat_count: number; + tabular_review_count: number; + workflow_count: number; +} + +export interface VeraMatterProfileWire { + project_id: string; + workspace_type: VeraWorkspaceType | null; + client_name: string | null; + jurisdiction: string | null; + represented_role: string | null; + objective: string | null; + created_at: string; + updated_at: string; +} + +export interface VeraMatterCapabilitiesWire { + matter_profile: "create" | "classify" | "edit" | "unavailable"; + inference: + | "workspace_compatibility" + | "policy_gate_closed" + | "unavailable"; + review: "unavailable"; + drafts: "document_scoped"; +} + +export interface VeraMatterWire { + project: VeraMatterProjectWire; + matter_profile: VeraMatterProfileWire | null; + profile_state: VeraMatterProfileState; + capabilities: VeraMatterCapabilitiesWire; +} + +export interface VeraMatterPageWire { + items: VeraMatterWire[]; + next_cursor: string | null; +} + +export interface VeraMatterListQuery { + status?: "active" | "archived"; + cursor?: string; + limit?: number; +} + +export interface VeraMatterProfileCreateWire { + workspace_type: VeraWorkspaceType; + client_name?: string | null; + jurisdiction?: string | null; + represented_role?: string | null; + objective?: string | null; +} + +export interface VeraMatterCreateWire extends VeraMatterProfileCreateWire { + name: string; + description?: string | null; + cm_number?: string | null; + practice?: string | null; +} + +export interface VeraMatterProfileUpdateWire { + workspace_type?: VeraWorkspaceType; + client_name?: string | null; + jurisdiction?: string | null; + represented_role?: string | null; + objective?: string | null; +} + +const UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const CANONICAL_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; +const BASE64URL = /^[A-Za-z0-9_-]+$/; + +const PROJECT_KEYS = [ + "id", + "name", + "description", + "cm_number", + "practice", + "status", + "default_model_profile_id", + "created_at", + "updated_at", + "archived_at", + "document_count", + "chat_count", + "tabular_review_count", + "workflow_count", +] as const; + +const PROFILE_KEYS = [ + "project_id", + "workspace_type", + "client_name", + "jurisdiction", + "represented_role", + "objective", + "created_at", + "updated_at", +] as const; + +const CAPABILITY_KEYS = [ + "matter_profile", + "inference", + "review", + "drafts", +] as const; + +const PROFILE_INPUT_LIMITS = { + client_name: 500, + jurisdiction: 240, + represented_role: 240, + objective: 16_384, +} as const; + +const PROFILE_CREATE_KEYS = [ + "workspace_type", + ...Object.keys(PROFILE_INPUT_LIMITS), +] as const; +const PROFILE_UPDATE_KEYS = [...PROFILE_CREATE_KEYS] as const; +const MATTER_CREATE_KEYS = [ + "name", + "description", + "cm_number", + "practice", + ...PROFILE_CREATE_KEYS, +] as const; + +function invalidWire(label: string): never { + throw new VeraApiError({ + status: 502, + code: "INVALID_RESPONSE", + message: `The Vera ${label} is invalid.`, + }); +} + +function invalidInput(label: string): never { + throw new VeraRuntimeConfigurationError(`The Vera ${label} is invalid.`); +} + +function record(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return invalidWire(label); + } + return value as Record; +} + +function exactKeys( + value: Record, + keys: readonly string[], + label: string, +) { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + invalidWire(label); + } +} + +function exactInputKeys( + value: unknown, + keys: readonly string[], + label: string, +): Record { + if ( + typeof value !== "object" || + value === null || + Array.isArray(value) || + (Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null) + ) { + invalidInput(label); + } + const input = value as Record; + if (Object.keys(input).some((key) => !keys.includes(key))) invalidInput(label); + return input; +} + +function codePointLength(value: string): number { + let length = 0; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code === 0) return -1; + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!Number.isFinite(next) || next < 0xdc00 || next > 0xdfff) return -1; + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return -1; + } + length += 1; + } + return length; +} + +function text(value: unknown, label: string, maximum: number): string { + if (typeof value !== "string") return invalidWire(label); + const length = codePointLength(value); + if (value.trim().length === 0 || length < 1 || length > maximum) { + return invalidWire(label); + } + return value; +} + +function nullableText( + value: unknown, + label: string, + maximum: number, + allowEmpty = false, +): string | null { + if (value === null) return null; + if (allowEmpty && typeof value === "string") { + const length = codePointLength(value); + if (length >= 0 && length <= maximum) return value; + } + return text(value, label, maximum); +} + +function uuid(value: unknown, label: string): string { + if (typeof value !== "string" || !UUID.test(value)) return invalidWire(label); + return value; +} + +function safeId(value: string, label: string): string { + if (typeof value !== "string" || !UUID.test(value)) return invalidInput(label); + return value; +} + +function nonNegativeInteger(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || Number(value) < 0) invalidWire(label); + return Number(value); +} + +function timestamp(value: unknown, label: string): string { + if (typeof value !== "string" || value.length > 64) return invalidWire(label); + if (Number.isNaN(new Date(value).valueOf())) return invalidWire(label); + return value; +} + +function canonicalUtc(value: unknown, label: string): string { + const parsed = timestamp(value, label); + if (!CANONICAL_UTC.test(parsed) || new Date(parsed).toISOString() !== parsed) { + return invalidWire(label); + } + return parsed; +} + +function parseMatterProject(value: unknown): VeraMatterProjectWire { + const wire = record(value, "Matter Project response"); + exactKeys(wire, PROJECT_KEYS, "Matter Project response"); + uuid(wire.id, "Matter Project id"); + text(wire.name, "Matter Project name", 240); + nullableText(wire.description, "Matter Project description", 2_000, true); + nullableText(wire.cm_number, "Matter Project Matter number", 160, true); + nullableText(wire.practice, "Matter Project practice area", 160, true); + if (!["active", "archived", "deleted"].includes(String(wire.status))) { + invalidWire("Matter Project status"); + } + if (wire.default_model_profile_id !== null) { + uuid(wire.default_model_profile_id, "Matter Project model profile id"); + } + timestamp(wire.created_at, "Matter Project created timestamp"); + timestamp(wire.updated_at, "Matter Project updated timestamp"); + if (wire.archived_at !== null) { + timestamp(wire.archived_at, "Matter Project archived timestamp"); + } + nonNegativeInteger(wire.document_count, "Matter Project document count"); + nonNegativeInteger(wire.chat_count, "Matter Project chat count"); + nonNegativeInteger( + wire.tabular_review_count, + "Matter Project Tabular review count", + ); + nonNegativeInteger(wire.workflow_count, "Matter Project workflow count"); + return wire as unknown as VeraMatterProjectWire; +} + +export function parseVeraMatterProfileWire( + value: unknown, +): VeraMatterProfileWire { + const wire = record(value, "Matter Profile response"); + exactKeys(wire, PROFILE_KEYS, "Matter Profile response"); + uuid(wire.project_id, "Matter Profile project id"); + if ( + wire.workspace_type !== null && + !VERA_WORKSPACE_TYPES.includes(wire.workspace_type as VeraWorkspaceType) + ) { + invalidWire("Matter Profile workspace type"); + } + nullableText(wire.client_name, "Matter Profile client name", 500); + nullableText(wire.jurisdiction, "Matter Profile jurisdiction", 240); + nullableText(wire.represented_role, "Matter Profile represented role", 240); + nullableText(wire.objective, "Matter Profile objective", 16_384); + const createdAt = canonicalUtc( + wire.created_at, + "Matter Profile created timestamp", + ); + const updatedAt = canonicalUtc( + wire.updated_at, + "Matter Profile updated timestamp", + ); + if (updatedAt < createdAt) invalidWire("Matter Profile timestamp ordering"); + return wire as unknown as VeraMatterProfileWire; +} + +function parseCapabilities(value: unknown): VeraMatterCapabilitiesWire { + const wire = record(value, "Matter capabilities response"); + exactKeys(wire, CAPABILITY_KEYS, "Matter capabilities response"); + if ( + !["create", "classify", "edit", "unavailable"].includes( + String(wire.matter_profile), + ) + ) { + invalidWire("Matter Profile capability"); + } + if ( + !["workspace_compatibility", "policy_gate_closed", "unavailable"].includes( + String(wire.inference), + ) + ) { + invalidWire("Matter inference capability"); + } + if (wire.review !== "unavailable" || wire.drafts !== "document_scoped") { + invalidWire("Matter feature capabilities"); + } + return wire as unknown as VeraMatterCapabilitiesWire; +} + +function expectedPresentation( + profile: VeraMatterProfileWire | null, + projectStatus: VeraMatterProjectWire["status"], +): Pick { + const profileState: VeraMatterProfileState = + profile === null + ? "absent" + : profile.workspace_type === null + ? "classification_required" + : "ready"; + if (projectStatus !== "active") { + return { + profile_state: profileState, + capabilities: { + matter_profile: "unavailable", + inference: "unavailable", + review: "unavailable", + drafts: "document_scoped", + }, + }; + } + if (profile === null) { + return { + profile_state: "absent", + capabilities: { + matter_profile: "create", + inference: "workspace_compatibility", + review: "unavailable", + drafts: "document_scoped", + }, + }; + } + return { + profile_state: + profile.workspace_type === null ? "classification_required" : "ready", + capabilities: { + matter_profile: profile.workspace_type === null ? "classify" : "edit", + inference: "policy_gate_closed", + review: "unavailable", + drafts: "document_scoped", + }, + }; +} + +export function parseVeraMatterWire(value: unknown): VeraMatterWire { + const wire = record(value, "Matter response"); + exactKeys( + wire, + ["project", "matter_profile", "profile_state", "capabilities"], + "Matter response", + ); + const project = parseMatterProject(wire.project); + const matterProfile = + wire.matter_profile === null + ? null + : parseVeraMatterProfileWire(wire.matter_profile); + if (matterProfile !== null && matterProfile.project_id !== project.id) { + invalidWire("Matter ownership response"); + } + if (!VERA_MATTER_PROFILE_STATES.includes(wire.profile_state as VeraMatterProfileState)) { + invalidWire("Matter Profile state"); + } + const capabilities = parseCapabilities(wire.capabilities); + const expected = expectedPresentation(matterProfile, project.status); + if ( + wire.profile_state !== expected.profile_state || + capabilities.matter_profile !== expected.capabilities.matter_profile || + capabilities.inference !== expected.capabilities.inference || + capabilities.review !== expected.capabilities.review || + capabilities.drafts !== expected.capabilities.drafts + ) { + invalidWire("Matter capability state"); + } + return { + project, + matter_profile: matterProfile, + profile_state: wire.profile_state as VeraMatterProfileState, + capabilities, + }; +} + +export function parseVeraMatterPageWire(value: unknown): VeraMatterPageWire { + const wire = record(value, "Matter page response"); + exactKeys(wire, ["items", "next_cursor"], "Matter page response"); + if (!Array.isArray(wire.items) || wire.items.length > 100) { + invalidWire("Matter page items"); + } + if ( + wire.next_cursor !== null && + (typeof wire.next_cursor !== "string" || + wire.next_cursor.length < 1 || + wire.next_cursor.length > 512 || + !BASE64URL.test(wire.next_cursor)) + ) { + invalidWire("Matter page cursor"); + } + return { + items: wire.items.map(parseVeraMatterWire), + next_cursor: wire.next_cursor as string | null, + }; +} + +function inputText( + value: unknown, + label: string, + maximum: number, + nullable: boolean, +): string | null | undefined { + if (value === undefined) return undefined; + if (value === null && nullable) return null; + if (typeof value !== "string" || value !== value.trim()) invalidInput(label); + const length = codePointLength(value as string); + if (length < 1 || length > maximum) invalidInput(label); + return value as string; +} + +function validateWorkspaceType(value: unknown, required: boolean) { + if (value === undefined && !required) return; + if (!VERA_WORKSPACE_TYPES.includes(value as VeraWorkspaceType)) { + invalidInput("Matter Profile workspace type"); + } +} + +function validateProfileInput( + input: unknown, + mode: "create" | "update", + wholeMatter = false, +) { + const wire = exactInputKeys( + input, + wholeMatter + ? MATTER_CREATE_KEYS + : mode === "create" + ? PROFILE_CREATE_KEYS + : PROFILE_UPDATE_KEYS, + "Matter Profile request", + ); + validateWorkspaceType(wire.workspace_type, mode === "create"); + for (const [key, maximum] of Object.entries(PROFILE_INPUT_LIMITS)) { + inputText(wire[key], `Matter Profile ${key}`, maximum, true); + } + if ( + mode === "update" && + (Object.keys(wire).length === 0 || + Object.values(wire).every((value) => value === undefined)) + ) { + invalidInput("Matter Profile update"); + } + return wire; +} + +function validateMatterCreateInput(input: unknown) { + const wire = validateProfileInput(input, "create", true); + if (wire.name === undefined) invalidInput("Matter name"); + inputText(wire.name, "Matter name", 240, false); + inputText(wire.description, "Matter description", 2_000, true); + inputText(wire.cm_number, "Matter number", 160, true); + inputText(wire.practice, "Matter practice area", 160, true); +} + +function matterQuery(query: unknown): VeraQuery { + const wire = exactInputKeys( + query, + ["status", "cursor", "limit"], + "Matter list query", + ); + if ( + wire.status !== undefined && + wire.status !== "active" && + wire.status !== "archived" + ) { + invalidInput("Matter list status"); + } + if ( + wire.cursor !== undefined && + (typeof wire.cursor !== "string" || + wire.cursor.length < 1 || + wire.cursor.length > 512 || + !BASE64URL.test(wire.cursor)) + ) { + invalidInput("Matter list cursor"); + } + if ( + wire.limit !== undefined && + (!Number.isInteger(wire.limit) || + Number(wire.limit) < 1 || + Number(wire.limit) > 100) + ) { + invalidInput("Matter list limit"); + } + return wire as VeraQuery; +} + +export async function listVeraMatters( + query: VeraMatterListQuery = {}, + signal?: AbortSignal, +): Promise { + return parseVeraMatterPageWire( + await veraApiRequest("/matters", { + query: matterQuery(query), + signal, + }), + ); +} + +export async function getVeraMatter( + projectId: string, + signal?: AbortSignal, +): Promise { + return parseVeraMatterWire( + await veraApiRequest( + `/matters/${safeId(projectId, "Matter project id")}`, + { signal }, + ), + ); +} + +export async function createVeraMatter( + input: VeraMatterCreateWire, + signal?: AbortSignal, +): Promise { + validateMatterCreateInput(input); + return parseVeraMatterWire( + await veraApiRequest("/matters", { + method: "POST", + json: input, + signal, + }), + ); +} + +function matterProfilePath(projectId: string): string { + return `/projects/${safeId(projectId, "Matter project id")}/matter-profile`; +} + +export async function getVeraMatterProfile( + projectId: string, + signal?: AbortSignal, +): Promise { + return parseVeraMatterWire( + await veraApiRequest(matterProfilePath(projectId), { signal }), + ); +} + +export async function createVeraMatterProfile( + projectId: string, + input: VeraMatterProfileCreateWire, + signal?: AbortSignal, +): Promise { + validateProfileInput(input, "create"); + return parseVeraMatterWire( + await veraApiRequest(matterProfilePath(projectId), { + method: "POST", + json: input, + signal, + }), + ); +} + +export async function updateVeraMatterProfile( + projectId: string, + input: VeraMatterProfileUpdateWire, + signal?: AbortSignal, +): Promise { + validateProfileInput(input, "update"); + return parseVeraMatterWire( + await veraApiRequest(matterProfilePath(projectId), { + method: "PATCH", + json: input, + signal, + }), + ); +} diff --git a/frontend/src/features/matter-overview/MatterNavigation.tsx b/frontend/src/features/matter-overview/MatterNavigation.tsx new file mode 100644 index 0000000..e50a8ee --- /dev/null +++ b/frontend/src/features/matter-overview/MatterNavigation.tsx @@ -0,0 +1,84 @@ +"use client"; + +import Link from "next/link"; +import { useI18n } from "@/app/i18n"; +import type { VeraMatterCapabilitiesWire } from "@/app/lib/veraMatterApi"; + +export function MatterNavigation({ + projectId, + capabilities, +}: { + projectId: string; + capabilities: VeraMatterCapabilitiesWire; +}) { + const { t } = useI18n(); + const unavailable = t("common.status.unavailable"); + const inferenceAvailable = + capabilities.inference === "workspace_compatibility"; + const inferenceUnavailableReason = + capabilities.inference === "unavailable" + ? t("matters.capabilities.readOnly") + : t("matters.capabilities.inferenceClosed"); + + const linkClass = + "shrink-0 text-xs text-gray-500 transition-colors hover:text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400"; + const disabledClass = + "inline-flex shrink-0 cursor-not-allowed items-center gap-1.5 text-xs text-gray-300"; + + return ( + + ); +} diff --git a/frontend/src/features/matter-overview/MatterProfileModal.tsx b/frontend/src/features/matter-overview/MatterProfileModal.tsx new file mode 100644 index 0000000..532b653 --- /dev/null +++ b/frontend/src/features/matter-overview/MatterProfileModal.tsx @@ -0,0 +1,363 @@ +"use client"; + +import { + useCallback, + useEffect, + useRef, + useState, + type FormEvent, +} from "react"; +import { Modal } from "@/app/components/shared/Modal"; +import { useProjectModalA11y } from "@/app/components/projects/useProjectModalA11y"; +import { useI18n } from "@/app/i18n"; +import { + createVeraMatter, + createVeraMatterProfile, + updateVeraMatterProfile, + VERA_WORKSPACE_TYPES, + type VeraMatterCreateWire, + type VeraMatterProfileCreateWire, + type VeraMatterProfileUpdateWire, + type VeraMatterProfileWire, + type VeraMatterProjectWire, + type VeraMatterWire, + type VeraWorkspaceType, +} from "@/app/lib/veraMatterApi"; + +export type MatterProfileModalMode = + | "create-matter" + | "create-profile" + | "edit-profile"; + +interface MatterProfileModalProps { + open: boolean; + mode: MatterProfileModalMode; + project?: VeraMatterProjectWire; + profile?: VeraMatterProfileWire | null; + onClose: () => void; + onSaved: (matter: VeraMatterWire) => void; +} + +type FormState = { + name: string; + description: string; + matterNumber: string; + practiceArea: string; + workspaceType: VeraWorkspaceType | ""; + clientName: string; + jurisdiction: string; + representedRole: string; + objective: string; +}; + +const EMPTY_FORM: FormState = { + name: "", + description: "", + matterNumber: "", + practiceArea: "", + workspaceType: "", + clientName: "", + jurisdiction: "", + representedRole: "", + objective: "", +}; + +function optionalText(value: string): string | null { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function profileFields(form: FormState): VeraMatterProfileCreateWire { + if (!form.workspaceType) throw new Error("Matter classification is required."); + return { + workspace_type: form.workspaceType, + client_name: optionalText(form.clientName), + jurisdiction: optionalText(form.jurisdiction), + represented_role: optionalText(form.representedRole), + objective: optionalText(form.objective), + }; +} + +function formFromMatter( + project?: VeraMatterProjectWire, + profile?: VeraMatterProfileWire | null, +): FormState { + return { + ...EMPTY_FORM, + name: project?.name ?? "", + description: project?.description ?? "", + matterNumber: project?.cm_number ?? "", + practiceArea: project?.practice ?? "", + workspaceType: profile?.workspace_type ?? "", + clientName: profile?.client_name ?? "", + jurisdiction: profile?.jurisdiction ?? "", + representedRole: profile?.represented_role ?? "", + objective: profile?.objective ?? "", + }; +} + +export function MatterProfileModal({ + open, + mode, + project, + profile, + onClose, + onSaved, +}: MatterProfileModalProps) { + const { t, errorMessage } = useI18n(); + const [form, setForm] = useState(EMPTY_FORM); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const requestRef = useRef(null); + const contentRef = useRef(null); + const formId = "vera-matter-profile-form"; + + useEffect(() => { + if (!open) return; + setForm(formFromMatter(project, profile)); + setSaving(false); + setError(null); + }, [open, profile, project]); + + const handleClose = useCallback(() => { + requestRef.current?.abort(); + requestRef.current = null; + setSaving(false); + setError(null); + onClose(); + }, [onClose]); + + const title = + mode === "create-matter" + ? t("matters.create") + : mode === "create-profile" + ? t("matters.profile.create") + : profile?.workspace_type === null + ? t("matters.profile.classify") + : t("matters.profile.edit"); + + useProjectModalA11y(open, handleClose, contentRef, title, mode); + useEffect(() => () => requestRef.current?.abort(), []); + + const update = ( + key: Key, + value: FormState[Key], + ) => { + setForm((current) => ({ ...current, [key]: value })); + setError(null); + }; + + const canSubmit = + !saving && + Boolean(form.workspaceType) && + (mode !== "create-matter" || Boolean(form.name.trim())); + + async function submit(event: FormEvent) { + event.preventDefault(); + if (!canSubmit) return; + const controller = new AbortController(); + requestRef.current?.abort(); + requestRef.current = controller; + setSaving(true); + setError(null); + try { + const fields = profileFields(form); + let saved: VeraMatterWire; + if (mode === "create-matter") { + const input: VeraMatterCreateWire = { + name: form.name.trim(), + description: optionalText(form.description), + cm_number: optionalText(form.matterNumber), + practice: optionalText(form.practiceArea), + ...fields, + }; + saved = await createVeraMatter(input, controller.signal); + } else if (mode === "create-profile" && project) { + saved = await createVeraMatterProfile( + project.id, + fields, + controller.signal, + ); + } else if (mode === "edit-profile" && project && profile) { + const input: VeraMatterProfileUpdateWire = fields; + saved = await updateVeraMatterProfile( + project.id, + input, + controller.signal, + ); + } else { + throw new Error("Matter Profile modal state is invalid."); + } + if (controller.signal.aborted) return; + onSaved(saved); + handleClose(); + } catch (cause) { + if (!controller.signal.aborted) setError(errorMessage(cause as Error)); + } finally { + if (requestRef.current === controller) requestRef.current = null; + if (!controller.signal.aborted) setSaving(false); + } + } + + return ( + + {error} + + ) : null + } + > +
+
+ {mode === "create-matter" && ( + <> + update("name", value)} + maxLength={240} + required + autoFocus + /> + update("description", value)} + maxLength={2_000} + /> + update("matterNumber", value)} + maxLength={160} + /> + update("practiceArea", value)} + maxLength={160} + /> + + )} + + + + update("clientName", value)} + maxLength={500} + /> + update("jurisdiction", value)} + maxLength={240} + /> + update("representedRole", value)} + maxLength={240} + /> + +