diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml new file mode 100644 index 0000000000..aed67f188a --- /dev/null +++ b/.github/workflows/release-candidate.yml @@ -0,0 +1,56 @@ +name: Release candidate evidence + +on: + workflow_dispatch: + inputs: + candidate_id: + description: Non-secret release candidate identifier + required: true + default: rc-unassigned + +permissions: + contents: read + +jobs: + verify-candidate: + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Check out candidate + uses: actions/checkout@v4 + + - name: Use Node.js 22 + uses: actions/setup-node@v4 + with: + node-version: 22.13.0 + cache: npm + cache-dependency-path: | + backend/package-lock.json + frontend/package-lock.json + website/package-lock.json + + - name: Install locked dependencies + run: npm run install:all + + - name: Run the complete engineering gate + run: npm run check + + - name: Confirm governed artifacts are current + run: npm run test:release-manifest + + - name: Archive non-secret candidate evidence + uses: actions/upload-artifact@v4 + with: + name: ross-${{ inputs.candidate_id }}-evidence + retention-days: 30 + if-no-files-found: error + path: | + reports/ontario-evaluation-v1.json + reports/legal-source-health-v1.json + reports/release-manifest-v1.json + config/release-approvals.v1.json + config/operations-readiness.v1.json + config/launch-readiness.v1.json + +# Deliberately no deploy job. Production promotion is a separate, human-approved +# action after every fail-closed record and external review is complete. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f43c37c68..8a3b522f97 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,8 @@ # Contributing -Thanks for helping improve Mike. Please keep contributions small, focused, and easy to review. +Thanks for helping improve ROSS, a modified fork of Mike. Please keep +contributions small, focused, and easy to review while preserving the inherited +Mike functionality recorded in the baseline contract. ## Guidelines @@ -9,12 +11,18 @@ Thanks for helping improve Mike. Please keep contributions small, focused, and e - Update docs or env examples when changing setup, config, or user-facing behavior. - Please do not propose local-hosting refactors for the main app, such as local LLMs, local databases, or local filesystem storage. Those ideas are better suited to a future fully local version of the project. - Do not commit secrets, API keys, private documents, or local `.env` files. +- Do not use confidential, privileged, regulated, proprietary, or real client + material in fixtures, previews, demonstrations, issues, or pull requests. +- Do not scrape CanLII. Licensed connectors require an approved agreement, + transport, entitlement, and activation record. ## Before Opening a PR - Install all dependencies from the repository root with `npm run install:all`. - Run the baseline contract tests with `npm test`. - Run the full local verification suite with `npm run check`. +- For a release-candidate change, regenerate and verify the governed manifest + with `npm run build:release-manifest` and `npm run test:release-manifest`. - If deployed test URLs are available, run `ROSS_E2E_API_URL=... ROSS_E2E_APP_URL=... npm run test:e2e`. - Check `git diff` and remove unrelated changes. - Write a concise Markdown PR description with: diff --git a/README.md b/README.md index 558d514891..59a20bbbee 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,9 @@ ROSS (Ranade OSS, fork of Mike OSS) is a legal document assistant with a Next.js frontend, an Express backend, Supabase Auth/Postgres, and Cloudflare R2-compatible object storage. -ROSS public website: planned; placeholder domains are recorded in -`config/ross-brand.json` until approved. +The ROSS public website source is in `website/`. Its current hosted checkpoint +is owner-only and no-index; placeholder production domains and owners remain in +`config/ross-brand.json` until independently approved. Upstream project: [Mike source](https://github.com/Open-Legal-Products/mike) and [Mike website](https://mikeoss.com). ROSS is independently developed and is not @@ -20,6 +21,10 @@ materials only. Architecture decisions and their approval status are indexed in - `backend/` - Express API, Supabase access, document processing, and database schema - `backend/schema.sql` - Supabase schema for fresh databases - `backend/migrations/` - dated, incremental schema migrations; on an existing database, apply the files dated after the Mike version you deployed +- `website/` - separate public website with governed Ontario, trust, coverage, + status, and launch-readiness content +- `config/` and `reports/` - versioned policy, approval, evaluation, + source-health, and immutable release-manifest records ## Prerequisites @@ -116,6 +121,7 @@ Install each app package: ```bash npm install --prefix backend npm install --prefix frontend +npm install --prefix website ``` ## Run Locally @@ -162,3 +168,23 @@ npm run check Use `npm run lint:strict` to inspect the inherited frontend lint debt separately from the non-regression gate. + +## Production and controlled-beta release boundary + +Self-hosted local development remains available. An operator-hosted beta is +limited to synthetic or affirmatively non-confidential material. Production is +not approved and fails closed while the operator, domains, vendors, effective +legal notices, independent reviews, live source health, and operational +exercises are pending. + +Start with these records and runbooks: + +- `config/release-approvals.v1.json` +- `config/operations-readiness.v1.json` +- `config/launch-readiness.v1.json` +- `docs/operations/release-runbook.md` +- `docs/operations/launch-checklist.md` + +`npm run release:check` is the production promotion gate. Do not replace a +blocked result with an environment flag or deploy a different artifact from the +one reviewed in `reports/release-manifest-v1.json`. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..6894cf6e9d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,24 @@ +# ROSS security policy + +ROSS is under active development and is not approved for confidential, +privileged, regulated, proprietary, or real client material in an +operator-hosted environment. + +## Reporting a vulnerability + +Use GitHub private vulnerability reporting for this repository. Do not include +client information, production credentials, unnecessary personal information, +or exploit details in a public issue. A public issue may be used for a +non-sensitive software defect only. + +The project does not yet publish a response-time commitment because the legal +operator and security owner are unassigned. The incident process in +`docs/security/incident-response.md` is the engineering response model, not an +operational service-level agreement. + +## Supported versions + +No production release is supported. The default branch is the only engineering +line receiving security changes. Fork operators are responsible for their own +deployment, access control, secrets, dependencies, logs, backups, retention, +vendors, monitoring, and incident response. diff --git a/backend/.env.example b/backend/.env.example index 4fab5d8162..723161251c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,6 +8,11 @@ ROSS_DATA_BOUNDARY_VERSION=2026-07-16 HOSTED_MODEL_PROVIDERS= # Production remains fail-closed until recorded legal/privacy/security approvals. ROSS_PRODUCTION_CONTROLS_APPROVED=false +# Required in production and must identify the exact approved candidate and the +# SHA-256 digest of its governed release manifest. These values are evidence +# references, not substitutes for npm run release:check. +ROSS_RELEASE_ID= +ROSS_RELEASE_MANIFEST_SHA256= # Comma-separated exact browser origins. FRONTEND_URL remains a compatibility fallback. CORS_ALLOWED_ORIGINS=http://localhost:3000 diff --git a/backend/src/config/runtime.test.ts b/backend/src/config/runtime.test.ts index 903a343b33..b879def3e0 100644 --- a/backend/src/config/runtime.test.ts +++ b/backend/src/config/runtime.test.ts @@ -8,6 +8,8 @@ const KEYS = [ "ROSS_HOSTED_MODE", "HOSTED_MODEL_PROVIDERS", "ROSS_PRODUCTION_CONTROLS_APPROVED", + "ROSS_RELEASE_ID", + "ROSS_RELEASE_MANIFEST_SHA256", "LOG_RAW_LLM_STREAM", "RAW_LLM_STREAM_LOG_DIR", "CORS_ALLOWED_ORIGINS", @@ -101,6 +103,9 @@ test("non-local raw model logging and unapproved production fail closed", () => R2_ACCESS_KEY_ID: "production-access-value", R2_SECRET_ACCESS_KEY: "production-storage-secret", R2_BUCKET_NAME: "ross-production", + ROSS_RELEASE_ID: "ross-2026-07-16-rc1", + ROSS_RELEASE_MANIFEST_SHA256: + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", }, () => assert.throws( @@ -109,3 +114,29 @@ test("non-local raw model logging and unapproved production fail closed", () => ), ); }); + +test("production requires a valid immutable release manifest identity", () => { + withEnvironment( + { + ROSS_ENV: "production", + ROSS_HOSTED_MODE: "production", + HOSTED_MODEL_PROVIDERS: "openai", + CORS_ALLOWED_ORIGINS: "https://app.ross.test", + SUPABASE_URL: "https://ross.supabase.co", + SUPABASE_SECRET_KEY: "production-secret-value", + DOWNLOAD_SIGNING_SECRET: "production-signing-value", + R2_ENDPOINT_URL: "https://objects.ross.test", + R2_ACCESS_KEY_ID: "production-access-value", + R2_SECRET_ACCESS_KEY: "production-storage-secret", + R2_BUCKET_NAME: "ross-production", + ROSS_RELEASE_ID: "ross-2026-07-16-rc1", + ROSS_RELEASE_MANIFEST_SHA256: "not-a-digest", + ROSS_PRODUCTION_CONTROLS_APPROVED: "true", + }, + () => + assert.throws( + () => loadRuntimeConfig(), + /ROSS_RELEASE_MANIFEST_SHA256 must be a lowercase SHA-256/, + ), + ); +}); diff --git a/backend/src/config/runtime.ts b/backend/src/config/runtime.ts index c9d33439d4..4d25bf1409 100644 --- a/backend/src/config/runtime.ts +++ b/backend/src/config/runtime.ts @@ -8,6 +8,8 @@ export type RuntimeConfig = { hostedMode: RossHostedMode; dataBoundaryVersion: string; hostedModelProviders: Array<"claude" | "gemini" | "openai">; + releaseId: string | null; + releaseManifestSha256: string | null; }; const PLACEHOLDER = @@ -129,8 +131,14 @@ export function loadRuntimeConfig(): RuntimeConfig { "R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY", "R2_BUCKET_NAME", + "ROSS_RELEASE_ID", + "ROSS_RELEASE_MANIFEST_SHA256", ]) requiredProductionValue(name); + if (!/^[a-f0-9]{64}$/.test(process.env.ROSS_RELEASE_MANIFEST_SHA256!)) + throw new Error( + "ROSS_RELEASE_MANIFEST_SHA256 must be a lowercase SHA-256 digest.", + ); if (allowedOrigins.some((origin) => PLACEHOLDER.test(origin))) { throw new Error( "Production CORS origins cannot use localhost or placeholder domains.", @@ -167,5 +175,8 @@ export function loadRuntimeConfig(): RuntimeConfig { dataBoundaryVersion: process.env.ROSS_DATA_BOUNDARY_VERSION?.trim() || "2026-07-16", hostedModelProviders: hostedModelProviders(currentHostedMode), + releaseId: process.env.ROSS_RELEASE_ID?.trim() || null, + releaseManifestSha256: + process.env.ROSS_RELEASE_MANIFEST_SHA256?.trim() || null, }; } diff --git a/backend/src/middleware/dataBoundary.test.ts b/backend/src/middleware/dataBoundary.test.ts index 45118d4208..11e741020f 100644 --- a/backend/src/middleware/dataBoundary.test.ts +++ b/backend/src/middleware/dataBoundary.test.ts @@ -13,6 +13,8 @@ const config: RuntimeConfig = { hostedMode: "controlled-beta", dataBoundaryVersion: "test-v1", hostedModelProviders: ["openai"], + releaseId: null, + releaseManifestSha256: null, }; test("content-bearing write routes are identified without blocking reads or deletion", () => { diff --git a/config/launch-readiness.v1.json b/config/launch-readiness.v1.json new file mode 100644 index 0000000000..f039c9b498 --- /dev/null +++ b/config/launch-readiness.v1.json @@ -0,0 +1,15 @@ +{ + "version": "1.0.0-draft", + "releaseId": "unassigned", + "status": "blocked-awaiting-launch-decisions", + "decisions": { + "legalOperator": { "status": "pending", "approver": null, "date": null, "evidence": null }, + "accountableOwners": { "status": "pending", "approver": null, "date": null, "evidence": null }, + "productionDomains": { "status": "pending", "approver": null, "date": null, "evidence": null }, + "vendorsAndResidency": { "status": "pending", "approver": null, "date": null, "evidence": null }, + "effectiveLegalNotices": { "status": "pending", "approver": null, "date": null, "evidence": null }, + "supportAndPrivacyChannels": { "status": "pending", "approver": null, "date": null, "evidence": null }, + "betaCohortAndTerms": { "status": "pending", "approver": null, "date": null, "evidence": null }, + "goLiveDecision": { "status": "pending", "approver": null, "date": null, "evidence": null } + } +} diff --git a/config/legal-source-operations.v1.json b/config/legal-source-operations.v1.json new file mode 100644 index 0000000000..153f4e5c86 --- /dev/null +++ b/config/legal-source-operations.v1.json @@ -0,0 +1,38 @@ +{ + "version": "1.0.0", + "policyId": "ross-legal-source-operations-v1", + "status": "engineering-policy-approved", + "defaultTimeoutMs": 10000, + "providers": { + "a2aj-canada": { + "requiredForProduction": true, + "maximumAgeHours": 24, + "quarantineAfterFailures": 3, + "recoverAfterSuccesses": 2 + }, + "ontario-elaws": { + "requiredForProduction": true, + "maximumAgeHours": 24, + "quarantineAfterFailures": 3, + "recoverAfterSuccesses": 2 + }, + "justice-laws-canada": { + "requiredForProduction": true, + "maximumAgeHours": 24, + "quarantineAfterFailures": 3, + "recoverAfterSuccesses": 2 + }, + "courtlistener-us": { + "requiredForProduction": false, + "maximumAgeHours": 72, + "quarantineAfterFailures": 3, + "recoverAfterSuccesses": 2 + }, + "canlii-licensed": { + "requiredForProduction": false, + "maximumAgeHours": 24, + "quarantineAfterFailures": 1, + "recoverAfterSuccesses": 2 + } + } +} diff --git a/config/operations-readiness.v1.json b/config/operations-readiness.v1.json new file mode 100644 index 0000000000..716837c08d --- /dev/null +++ b/config/operations-readiness.v1.json @@ -0,0 +1,16 @@ +{ + "version": "1.0.0-draft", + "releaseId": "unassigned", + "status": "blocked-awaiting-operational-evidence", + "evidence": { + "ci": { "status": "pending", "approver": null, "date": null, "evidence": null }, + "stagingJourney": { "status": "pending", "approver": null, "date": null, "evidence": null }, + "migrationDryRun": { "status": "pending", "approver": null, "date": null, "evidence": null }, + "backupRestore": { "status": "pending", "approver": null, "date": null, "evidence": null }, + "rollbackExercise": { "status": "pending", "approver": null, "date": null, "evidence": null }, + "observability": { "status": "pending", "approver": null, "date": null, "evidence": null }, + "sourceHealth": { "status": "pending", "approver": null, "date": null, "evidence": null }, + "dependencyReview": { "status": "pending", "approver": null, "date": null, "evidence": null }, + "incidentExercise": { "status": "pending", "approver": null, "date": null, "evidence": null } + } +} diff --git a/config/release-manifest.v1.json b/config/release-manifest.v1.json new file mode 100644 index 0000000000..1330a367cc --- /dev/null +++ b/config/release-manifest.v1.json @@ -0,0 +1,20 @@ +{ + "version": "1.0.0", + "releaseId": "unassigned", + "generatedAt": "2026-07-16T00:00:00.000Z", + "artifacts": [ + "backend/schema.sql", + "backend/src/config/runtime.ts", + "config/legal-source-operations.v1.json", + "config/launch-readiness.v1.json", + "config/operations-readiness.v1.json", + "config/public-source-coverage.json", + "config/release-approvals.v1.json", + "config/ross-brand.json", + "reports/legal-source-health-v1.json", + "reports/ontario-evaluation-v1.json", + "scripts/lib/release-readiness.mjs", + "website/app/site-config.ts", + "workflows/ontario/catalogue.json" + ] +} diff --git a/docs/architecture/ADR-012-release-and-source-governance.md b/docs/architecture/ADR-012-release-and-source-governance.md index fd7e6f7790..fee331ee24 100644 --- a/docs/architecture/ADR-012-release-and-source-governance.md +++ b/docs/architecture/ADR-012-release-and-source-governance.md @@ -34,5 +34,8 @@ Ontario evaluation gates are implemented and assigned owners. ## Follow-up -- [ ] Define release artefacts and approval matrix in ROSS-150 and ROSS-170. -- [ ] Add source health, freshness, quarantine, and rollback procedures. +- [x] Define release artifacts and approval matrices in ROSS-150 and ROSS-170. +- [x] Add source health, freshness, quarantine, and rollback procedures. +- [ ] Assign owners and complete staging, restore, rollback, source, security, + accessibility, legal-content, privacy, and launch evidence for one immutable + candidate. diff --git a/docs/operations/backup-restore.md b/docs/operations/backup-restore.md new file mode 100644 index 0000000000..44e7103faa --- /dev/null +++ b/docs/operations/backup-restore.md @@ -0,0 +1,25 @@ +# Backup and restore exercise + +Production remains blocked until the selected database, object storage, auth, +configuration, and audit systems have documented backup scope and a successful +isolated restore exercise. + +## Required evidence + +1. Inventory every stateful system, encryption key dependency, backup region, + retention period, access role, and deletion limitation. +2. Create a backup from synthetic staging data containing a known record set and + file hashes. Confirm encryption and least-privilege access. +3. Restore into a new isolated environment—not over staging or production. +4. Apply migrations in documented order and verify counts, hashes, ownership, + sharing boundaries, soft-deletion state, download controls, and audit access. +5. Exercise authentication recovery without copying production secrets. +6. Run the complete engineering gate and the staging user journey against the + restored environment. +7. Record recovery point and recovery time observations. The product owner must + approve targets before they are described as commitments. +8. Destroy the exercise environment and confirm its backup/secret cleanup. + +A database-only restore is insufficient when object storage, auth identities, +keys, or source versions are required to make records usable and correctly +isolated. Backup expiry and deletion propagation remain privacy-review items. diff --git a/docs/operations/evidence-template.md b/docs/operations/evidence-template.md new file mode 100644 index 0000000000..77849275d0 --- /dev/null +++ b/docs/operations/evidence-template.md @@ -0,0 +1,22 @@ +# Operational evidence template + +- Candidate ID: +- Commit SHA: +- Environment and immutable deployment ID: +- Control exercised: +- Owner: +- Independent reviewer, if required: +- Started/finished in UTC: +- Synthetic or approved non-confidential data used: +- Preconditions: +- Exact procedure/version: +- Expected result: +- Observed result: +- Logs, screenshots, reports, or ticket paths (non-secret): +- Deviations and residual risk: +- Cleanup completed: +- Result: `pass` / `fail` / `blocked` +- Approval name and ISO date: + +Do not place credentials, access tokens, private vulnerability details, client +facts, privileged material, or raw production content in release evidence. diff --git a/docs/operations/launch-checklist.md b/docs/operations/launch-checklist.md new file mode 100644 index 0000000000..61be8f165a --- /dev/null +++ b/docs/operations/launch-checklist.md @@ -0,0 +1,42 @@ +# Controlled-beta launch checklist + +The current owner-only website checkpoint is not a public launch. Complete every +item below for the same candidate before changing access, indexing, invitations, +or the production data boundary. + +## Accountable decisions + +- [ ] Name the legal operator and product, technical, legal-content, privacy, + security, accessibility, release, support, and incident owners. +- [ ] Approve production domains, Canadian-region vendors, subprocessors, + contracts, support access, retention, deletion, backups, and incident terms. +- [ ] Publish effective privacy, terms, acceptable-use, accessibility, security, + support, privacy-contact, and subprocessor information. +- [ ] Approve the beta cohort, invitation/revocation process, user terms, + permitted-data boundary, feedback route, and exit process. +- [ ] Complete Ontario lawyer, privacy, security/penetration, accessibility, AGPL, + and product reviews with dated evidence. + +## Technical evidence + +- [ ] Complete CI, staging journey, migration, backup/restore, rollback, + observability, source-health, dependency, and incident exercises. +- [ ] Confirm tenant/RLS/IDOR boundaries, upload/conversion isolation, rate + limiting, WAF/abuse controls, secret rotation, vulnerability response, and + production logging restrictions. +- [ ] Verify source currency, coverage display, quarantine, independent Ontario + benchmark results, and preserved Mike regression coverage. +- [ ] Verify corresponding-source links, notices, release manifest, and SBOM or + equivalent reviewed dependency inventory. + +## Go-live controls + +- [ ] `npm run release:check` passes for the immutable candidate. +- [ ] The go/no-go record names the decision maker, time window, monitoring + owner, rollback owner, and stop conditions. +- [ ] Public indexing remains disabled unless the public-content, legal, privacy, + accessibility, and product owners explicitly approve it. +- [ ] Run a limited synthetic/non-confidential cohort first. Expansion to real + confidential or privileged material requires a successor data-boundary ADR. + +Any unchecked, expired, contradictory, or candidate-mismatched item is a blocker. diff --git a/docs/operations/legal-source-operations.md b/docs/operations/legal-source-operations.md new file mode 100644 index 0000000000..2e56817f78 --- /dev/null +++ b/docs/operations/legal-source-operations.md @@ -0,0 +1,25 @@ +# Legal-source health, quarantine, and recovery + +`config/legal-source-operations.v1.json` defines required providers, maximum +observation age, failure thresholds, and recovery thresholds. A deployment +collector writes a sanitized `reports/legal-source-health-v1.json`; the checked +in report is deliberately marked **not observed** because no production source +monitor has run. + +For each check, record only provider ID, state, time, source/dataset version, +failure/success counters, latency class, and a sanitized reason code. Do not log +queries, passages, user identifiers, tokens, or response bodies. + +- A required provider that is stale, unavailable, degraded, unobserved, or + quarantined blocks production promotion. +- Three consecutive failures quarantine implemented providers by default. +- A quarantined provider requires two consecutive successes plus owner review + before recovery. +- CanLII remains disabled unless a separate licence, approved transport, and + activation review exist. Health policy never authorizes scraping. +- Optional CourtListener failure must remain visible but does not block the + Ontario production gate. + +Run `npm run source:check` against an actual candidate observation. A failure +keeps the provider disabled or the release blocked. Source health is distinct +from comprehensive court/date coverage and from legal accuracy review. diff --git a/docs/operations/observability.md b/docs/operations/observability.md new file mode 100644 index 0000000000..8fe1a8557c --- /dev/null +++ b/docs/operations/observability.md @@ -0,0 +1,26 @@ +# Observability and service objectives + +No service-level objective or public availability commitment is approved. The +operations owner must select infrastructure and set measurable objectives before +launch. + +## Minimum signals + +- website, application, API, authentication, database, object storage, email, + conversion, model-provider, and legal-source availability; +- request volume, latency, error class, rate-limit activity, queue saturation, + storage capacity, migration state, backup age, restore-test age, and source + observation age; +- security events including repeated auth failure, access denial, data-boundary + rejection, privilege changes, secret rotation, and audit-pipeline failure; +- release, workflow, prompt, schema, provider, and source-version identifiers. + +Telemetry must be allowlisted and metadata-only. Raw prompts, documents, model +streams, source passages, credentials, and client identifiers are prohibited. +Alerts need an owner, severity, tested route, acknowledgement expectation, +escalation path, suppression rule, and runbook. Exercise one critical alert and +one dependency failure in staging and retain evidence before approval. + +Public status must not disclose private security details. It must distinguish +the website, authenticated application, API, legal-source availability, and +known coverage limitations. diff --git a/docs/operations/release-runbook.md b/docs/operations/release-runbook.md new file mode 100644 index 0000000000..6f17058cb1 --- /dev/null +++ b/docs/operations/release-runbook.md @@ -0,0 +1,48 @@ +# Release-candidate runbook + +This runbook creates evidence; it does not authorize production. The release +owner must use a unique candidate identifier and keep code, schema, workflows, +legal-source policy, evaluation output, and approvals tied to the same commit. + +## Candidate sequence + +1. Start from a reviewed commit on `main`; confirm the worktree and generated + files are clean. +2. Set a unique release ID in every governed record. Do not reuse an identifier. +3. Run `npm run install:all`, `npm run check`, and + `npm run build:release-manifest` using the locked dependency files. +4. Run the GitHub **Release candidate evidence** workflow for the same commit. + Retain its URL, commit SHA, logs, and downloaded evidence artifact. +5. Deploy that immutable candidate to isolated staging. Never substitute a + different build after review. +6. Complete the staging journey, migration dry run, backup/restore exercise, + rollback exercise, monitoring test, source-health observation, dependency + review, and incident exercise. Use `evidence-template.md` for each result. +7. Obtain the independent Ontario legal-content, privacy, security, + accessibility, and product approvals. Approval authors must be independent + of the implementation where the applicable review requires independence. +8. Populate the release, operations, and launch JSON records with names, ISO + dates, and non-secret evidence paths. Never commit secrets or client data. +9. Run `npm run release:check`. A blocked result ends the promotion attempt. +10. Record a time-bounded go/no-go decision. Promote only the reviewed artifact, + monitor the rollout, and be prepared to execute the rollback runbook. + +## Required release bundle + +- commit SHA and immutable build/deployment identifier; +- generated SHA-256 release manifest; +- database migration set and tested rollback/forward-fix decision; +- Ontario evaluation report and independent review evidence; +- live source-health report and coverage limitations; +- approval, operational-readiness, and launch-readiness records; +- dependency/licence notice and vulnerability-review evidence; +- backup/restore, rollback, monitoring, incident, and accessibility evidence; +- release notes separating code, schema, source, workflow, prompt, policy, and + public-content changes. + +## Stop conditions + +Stop when any required check is missing, stale, inconsistent with the candidate, +or failed; an owner cannot be reached; a source is quarantined; a migration or +restore is unproven; material coverage changed; or the production data boundary +is broader than the approved privacy and security evidence. diff --git a/docs/operations/rollback.md b/docs/operations/rollback.md new file mode 100644 index 0000000000..e7784fbc52 --- /dev/null +++ b/docs/operations/rollback.md @@ -0,0 +1,35 @@ +# Rollback and forward-fix runbook + +The release owner decides rollback versus forward fix with the incident lead and +data owner. Schema compatibility and user-data integrity take priority over +speed. Never run an unreviewed destructive database rollback. + +## Before promotion + +- identify the last known-good application, schema, workflow, prompt, source, + content, and configuration versions; +- classify every migration as backward-compatible, forward-fix-only, or + restored-from-backup; +- retain immutable artifacts and tested traffic/configuration reversal steps; +- state the user-visible degradation and communication path; +- test the sequence in staging with synthetic data. + +## During rollback + +1. Freeze further changes and record timestamps and decision owners. +2. Disable or quarantine the smallest unsafe component. A legal source can be + disabled independently of the application. +3. Preserve metadata-only diagnostic evidence without copying user content. +4. Repoint traffic only to an artifact compatible with the current schema. +5. If data restoration is required, follow `backup-restore.md` and reconcile + writes accepted after the recovery point. +6. Re-run health, tenant isolation, authentication, source, evaluation, and + critical user-journey checks. +7. Communicate known impact and uncertainty. Continue monitoring after recovery. + +## Source rollback + +Quarantine the provider or dataset version, keep its limitation visible, restore +the last reviewed snapshot only when its currency limitations are explicit, and +require legal-content review before re-enabling it. Never silently present a +stale snapshot as current law. diff --git a/docs/ross-170-verification.md b/docs/ross-170-verification.md new file mode 100644 index 0000000000..d0c503d073 --- /dev/null +++ b/docs/ross-170-verification.md @@ -0,0 +1,22 @@ +# ROSS-170 verification + +## Implemented + +- Versioned legal-source freshness, quarantine, and recovery policy. +- Sanitized pre-production source-health record that cannot be mistaken for a + live check. +- Deterministic source operations tests for freshness, required/optional + providers, quarantine, and recovery. +- SHA-256 manifest for governed release artifacts with a freshness check. +- Evidence-bearing operational release gate covering CI, staging, migrations, + backup/restore, rollback, observability, sources, dependencies, and incidents. +- Manual GitHub release-candidate evidence workflow with no deployment job. +- Executable release, evidence, backup/restore, rollback, source, observability, + and security reporting runbooks. + +## Boundary + +No live infrastructure, backup, restore, penetration test, source observation, +incident drill, vendor, domain, or operator has been selected or exercised by +these files. Their records remain pending and production intentionally fails +closed. diff --git a/docs/ross-180-verification.md b/docs/ross-180-verification.md new file mode 100644 index 0000000000..a4d7172e1a --- /dev/null +++ b/docs/ross-180-verification.md @@ -0,0 +1,20 @@ +# ROSS-180 verification + +## Implemented + +- Versioned launch decision record for operator, owners, domains, vendors, + legal notices, contacts, cohort terms, and final go-live approval. +- A controlled-beta launch checklist tied to the same immutable release + candidate and production gate. +- Fail-closed website indexing flag and governed public launch-readiness status. +- Dated release-readiness public update with explicit unresolved blockers. +- Production release evaluation requires completed launch decisions in addition + to legal, privacy, security, accessibility, product, operational, source, and + Ontario benchmark evidence. + +## Boundary + +This is a launch-control implementation, not launch approval. The owner-only +checkpoint, synthetic/non-confidential restriction, no-index posture, `TBD` +operator, `.invalid` domains, draft notices, and all independent review blockers +remain in force. diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 44a809c9ce..2262c24b18 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -24,7 +24,7 @@ Real confidential, privileged, proprietary, regulated, or client material is out | Malicious files/conversion | Existing type/size validation and isolated service boundary in the target topology | Malware scanning, conversion sandbox verification, resource limits, and penetration testing remain required | | Unsafe sharing/export | Existing server authorization and rate limits; metadata-only audit table foundation | Complete audit hooks and sharing/export IDOR tests remain required | | Source poisoning or stale law | Official/allowlisted sources, provider metadata, source hashes/checks, visible verification states | Human stale-source response and comprehensive citator coverage remain incomplete | -| Availability or abuse | Helmet, exact CORS, bounded payloads, route-specific rate limits | Distributed limiter, WAF configuration, monitoring, backup, and failover belong to Delivery C | +| Availability or abuse | Helmet, exact CORS, bounded payloads, route-specific rate limits, fail-closed source policy, release/rollback/restore runbooks | Distributed limiter, WAF, monitoring, backups, restore and failover still require selected infrastructure and exercised evidence | ## Abuse cases diff --git a/package.json b/package.json index eba33daedf..1b3b47d74d 100644 --- a/package.json +++ b/package.json @@ -8,18 +8,22 @@ }, "scripts": { "install:all": "npm ci --prefix backend && npm ci --prefix frontend && npm ci --prefix website", - "test": "npm run test:workflow-sources && npm run test:public-content && npm run test:evaluation && npm run test:baseline && npm run test:legal-sources --prefix backend && npm run test:security --prefix backend", + "test": "npm run test:workflow-sources && npm run test:public-content && npm run test:release-manifest && npm run test:operations && npm run test:evaluation && npm run test:baseline && npm run test:legal-sources --prefix backend && npm run test:security --prefix backend", "test:evaluation": "node --test tests/evaluation/*.test.mjs && node scripts/evaluate-ontario.mjs --check && node scripts/check-release-readiness.mjs", "evaluate:ontario": "node scripts/evaluate-ontario.mjs", "release:check": "node scripts/check-release-readiness.mjs --production", + "source:check": "node scripts/check-source-operations.mjs --production", + "test:operations": "node --test tests/operations/*.test.mjs && node scripts/check-source-operations.mjs", + "test:release-manifest": "node scripts/build-release-manifest.mjs --check", "test:workflow-sources": "node scripts/build-ross-workflows.mjs --check", "test:public-content": "node scripts/build-public-content.mjs --check", "test:baseline": "node --test tests/baseline/*.test.mjs", "test:e2e": "node --test tests/e2e/*.test.mjs", "test:website": "npm run test:routes --prefix website", - "build": "npm run build:public-content && npm run build:ross-workflows && npm run build:backend && npm run build:frontend && npm run build:website", + "build": "npm run build:public-content && npm run build:ross-workflows && npm run build:release-manifest && npm run build:backend && npm run build:frontend && npm run build:website", "build:public-content": "node scripts/build-public-content.mjs", "build:ross-workflows": "node scripts/build-ross-workflows.mjs", + "build:release-manifest": "node scripts/build-release-manifest.mjs", "build:backend": "npm run build --prefix backend", "build:frontend": "node scripts/build-frontend.mjs", "build:website": "npm run build --prefix website", diff --git a/reports/legal-source-health-v1.json b/reports/legal-source-health-v1.json new file mode 100644 index 0000000000..3f38fd43c0 --- /dev/null +++ b/reports/legal-source-health-v1.json @@ -0,0 +1,13 @@ +{ + "version": "1.0.0", + "observedAt": "2026-07-16T00:00:00.000Z", + "liveChecksPerformed": false, + "status": "pre-production-not-observed", + "providers": { + "a2aj-canada": { "state": "not-observed", "checkedAt": null, "lastSuccessfulAt": null, "consecutiveFailures": 0, "consecutiveSuccesses": 0, "sourceVersion": null }, + "ontario-elaws": { "state": "not-observed", "checkedAt": null, "lastSuccessfulAt": null, "consecutiveFailures": 0, "consecutiveSuccesses": 0, "sourceVersion": null }, + "justice-laws-canada": { "state": "not-observed", "checkedAt": null, "lastSuccessfulAt": null, "consecutiveFailures": 0, "consecutiveSuccesses": 0, "sourceVersion": null }, + "courtlistener-us": { "state": "not-observed", "checkedAt": null, "lastSuccessfulAt": null, "consecutiveFailures": 0, "consecutiveSuccesses": 0, "sourceVersion": null }, + "canlii-licensed": { "state": "disabled", "checkedAt": null, "lastSuccessfulAt": null, "consecutiveFailures": 0, "consecutiveSuccesses": 0, "sourceVersion": null } + } +} diff --git a/reports/release-manifest-v1.json b/reports/release-manifest-v1.json new file mode 100644 index 0000000000..479946eeca --- /dev/null +++ b/reports/release-manifest-v1.json @@ -0,0 +1,74 @@ +{ + "version": "1.0.0", + "releaseId": "unassigned", + "generatedAt": "2026-07-16T00:00:00.000Z", + "algorithm": "sha256", + "artifactCount": 13, + "artifacts": [ + { + "path": "backend/schema.sql", + "sha256": "a802879090542e04152ef55a0faf47b78d2726d15859eb438b5537c1132d8026", + "sizeBytes": 27712 + }, + { + "path": "backend/src/config/runtime.ts", + "sha256": "b24c1d7c6848e6c7237c71487b061d3ca4aec843db3a14f65676f2d5fa5208f6", + "sizeBytes": 5963 + }, + { + "path": "config/legal-source-operations.v1.json", + "sha256": "ae74d9bc6cb411cf8aec077d70354605efb3444be24c98d49506da28e5efffb1", + "sizeBytes": 1003 + }, + { + "path": "config/launch-readiness.v1.json", + "sha256": "b1be324e2e5193d05914be0d8193f997ebcab685078025b7cc67e8b6e6b8f98e", + "sizeBytes": 938 + }, + { + "path": "config/operations-readiness.v1.json", + "sha256": "311f92bee6fcb63f88f0d4023406518eb9a71adff2a87cc51fa3319ae357bb68", + "sizeBytes": 997 + }, + { + "path": "config/public-source-coverage.json", + "sha256": "9b59797073dd36364a4ebf71b416dde1715ece7bef98b96678beb6c1fb198919", + "sizeBytes": 3632 + }, + { + "path": "config/release-approvals.v1.json", + "sha256": "3e6df14ffc9ed377befde53f25f00b5a46010527e622de967969199c7926a5f1", + "sizeBytes": 739 + }, + { + "path": "config/ross-brand.json", + "sha256": "97fd6fa842758de709dbed7ad08adf72be86e7636d6f939f12031d859e6cbfba", + "sizeBytes": 1972 + }, + { + "path": "reports/legal-source-health-v1.json", + "sha256": "b347e4ddb627fcbc4f67392a1e71cd84d01b52c01b97c8d6abeb7d011e63ab80", + "sizeBytes": 1025 + }, + { + "path": "reports/ontario-evaluation-v1.json", + "sha256": "67dc3e089dfb30a0d7565713cc6c2104f1908a5fc2fd4c634ca37e174e5f6800", + "sizeBytes": 5396 + }, + { + "path": "scripts/lib/release-readiness.mjs", + "sha256": "6592d96c8d0669800a0fa38c4eb8966e0882b1e18f966334b29524abca6b8b9d", + "sizeBytes": 2944 + }, + { + "path": "website/app/site-config.ts", + "sha256": "05ac5b38d330a90e4cc27a55c2e2b43904a104c9f580eeb98ae02f3fffaa2866", + "sizeBytes": 2693 + }, + { + "path": "workflows/ontario/catalogue.json", + "sha256": "406218eb0bbe6b8503cd7efc68d485c0be7374c4e1b7992a9d06c983196c87c4", + "sizeBytes": 10399 + } + ] +} diff --git a/scripts/build-release-manifest.mjs b/scripts/build-release-manifest.mjs new file mode 100644 index 0000000000..4036b7f947 --- /dev/null +++ b/scripts/build-release-manifest.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const config = JSON.parse( + readFileSync(resolve(root, "config/release-manifest.v1.json"), "utf8"), +); +const hash = (bytes) => createHash("sha256").update(bytes).digest("hex"); +const artifacts = config.artifacts.map((path) => { + const bytes = readFileSync(resolve(root, path)); + return { path, sha256: hash(bytes), sizeBytes: bytes.byteLength }; +}); +const report = { + version: config.version, + releaseId: config.releaseId, + generatedAt: config.generatedAt, + algorithm: "sha256", + artifactCount: artifacts.length, + artifacts, +}; +const outputPath = resolve(root, "reports/release-manifest-v1.json"); +const output = `${JSON.stringify(report, null, 2)}\n`; + +if (process.argv.includes("--check")) { + if (!existsSync(outputPath) || readFileSync(outputPath, "utf8") !== output) { + console.error("Release manifest is missing or stale. Run npm run build:release-manifest."); + process.exitCode = 1; + } else { + console.log(`PASS: release manifest covers ${artifacts.length} governed artifacts.`); + } +} else { + writeFileSync(outputPath, output); + console.log(`Wrote reports/release-manifest-v1.json with ${artifacts.length} artifacts.`); +} diff --git a/scripts/check-release-readiness.mjs b/scripts/check-release-readiness.mjs index 463d923680..a103d71135 100644 --- a/scripts/check-release-readiness.mjs +++ b/scripts/check-release-readiness.mjs @@ -4,15 +4,25 @@ import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { evaluateReleaseReadiness } from "./lib/release-readiness.mjs"; +import { evaluateSourceOperations } from "./lib/source-operations.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const readJson = (path) => JSON.parse(readFileSync(resolve(root, path), "utf8")); const production = process.argv.includes("--production"); +const sourceOperations = evaluateSourceOperations( + readJson("config/legal-source-operations.v1.json"), + readJson("reports/legal-source-health-v1.json"), +); const result = evaluateReleaseReadiness( readJson("reports/ontario-evaluation-v1.json"), readJson("config/release-approvals.v1.json"), production, + { + operations: readJson("config/operations-readiness.v1.json"), + launch: readJson("config/launch-readiness.v1.json"), + sourceOperations, + }, ); console.log( diff --git a/scripts/check-source-operations.mjs b/scripts/check-source-operations.mjs new file mode 100644 index 0000000000..ead2f22e23 --- /dev/null +++ b/scripts/check-source-operations.mjs @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { evaluateSourceOperations } from "./lib/source-operations.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const readJson = (path) => JSON.parse(readFileSync(resolve(root, path), "utf8")); +const result = evaluateSourceOperations( + readJson("config/legal-source-operations.v1.json"), + readJson("reports/legal-source-health-v1.json"), +); +const production = process.argv.includes("--production"); + +console.log( + production + ? `${result.ready ? "PASS" : "BLOCKED"}: legal-source operational gate.` + : "PASS: legal-source operations policy validated; live production observations remain separate.", +); +for (const blocker of result.blockers) console.error(`- ${blocker}`); +if (production && !result.ready) process.exitCode = 1; diff --git a/scripts/lib/release-readiness.mjs b/scripts/lib/release-readiness.mjs index 78d1f23b76..7c7d52df1e 100644 --- a/scripts/lib/release-readiness.mjs +++ b/scripts/lib/release-readiness.mjs @@ -6,6 +6,29 @@ const REQUIRED_APPROVALS = [ "productOwner", ]; +const REQUIRED_OPERATIONAL_EVIDENCE = [ + "ci", + "stagingJourney", + "migrationDryRun", + "backupRestore", + "rollbackExercise", + "observability", + "sourceHealth", + "dependencyReview", + "incidentExercise", +]; + +const REQUIRED_LAUNCH_DECISIONS = [ + "legalOperator", + "accountableOwners", + "productionDomains", + "vendorsAndResidency", + "effectiveLegalNotices", + "supportAndPrivacyChannels", + "betaCohortAndTerms", + "goLiveDecision", +]; + const isDatedApproval = (approval) => approval?.status === "approved" && typeof approval.approver === "string" && @@ -15,7 +38,19 @@ const isDatedApproval = (approval) => typeof approval.evidence === "string" && approval.evidence.trim().length > 0; -export function evaluateReleaseReadiness(report, approvalRecord, production) { +function requireEvidence(record, names, label, blockers) { + for (const name of names) { + if (!isDatedApproval(record?.[name])) + blockers.push(`${label} ${name} is missing or incomplete.`); + } +} + +export function evaluateReleaseReadiness( + report, + approvalRecord, + production, + productionEvidence = {}, +) { const blockers = []; if (report.passed !== true) blockers.push("Ontario evaluation thresholds failed."); @@ -34,6 +69,24 @@ export function evaluateReleaseReadiness(report, approvalRecord, production) { blockers.push(`${name} approval is missing or incomplete.`); } } + if (productionEvidence.operations?.status !== "approved-for-release") + blockers.push("Operational readiness record is not approved-for-release."); + requireEvidence( + productionEvidence.operations?.evidence, + REQUIRED_OPERATIONAL_EVIDENCE, + "operational evidence", + blockers, + ); + if (productionEvidence.launch?.status !== "approved-for-launch") + blockers.push("Launch readiness record is not approved-for-launch."); + requireEvidence( + productionEvidence.launch?.decisions, + REQUIRED_LAUNCH_DECISIONS, + "launch decision", + blockers, + ); + if (productionEvidence.sourceOperations?.ready !== true) + blockers.push("Required legal-source health is not production-ready."); } return { @@ -41,5 +94,7 @@ export function evaluateReleaseReadiness(report, approvalRecord, production) { ready: blockers.length === 0, blockers, requiredApprovals: REQUIRED_APPROVALS, + requiredOperationalEvidence: REQUIRED_OPERATIONAL_EVIDENCE, + requiredLaunchDecisions: REQUIRED_LAUNCH_DECISIONS, }; } diff --git a/scripts/lib/source-operations.mjs b/scripts/lib/source-operations.mjs new file mode 100644 index 0000000000..1220b7edb0 --- /dev/null +++ b/scripts/lib/source-operations.mjs @@ -0,0 +1,67 @@ +const VALID_STATES = new Set([ + "healthy", + "degraded", + "unavailable", + "quarantined", + "disabled", + "not-observed", +]); + +const hoursSince = (timestamp, now) => + (now.getTime() - new Date(timestamp).getTime()) / 3_600_000; + +export function evaluateSourceOperations(policy, report, now = new Date()) { + const blockers = []; + const providers = {}; + for (const [providerId, rule] of Object.entries(policy.providers ?? {})) { + const observation = report.providers?.[providerId]; + const reasons = []; + if (!observation) { + reasons.push("health observation is missing"); + } else if (!VALID_STATES.has(observation.state)) { + reasons.push(`health state ${observation.state} is invalid`); + } else if (observation.state !== "disabled") { + if (!observation.checkedAt) { + reasons.push("health check time is missing"); + } else { + const age = hoursSince(observation.checkedAt, now); + if (!Number.isFinite(age) || age < 0) + reasons.push("health check time is invalid"); + else if (age > rule.maximumAgeHours) + reasons.push(`health observation is older than ${rule.maximumAgeHours} hours`); + } + if (observation.consecutiveFailures >= rule.quarantineAfterFailures) + reasons.push("failure threshold requires quarantine"); + if (observation.state !== "healthy") + reasons.push(`provider state is ${observation.state}`); + } + + const ready = reasons.length === 0; + providers[providerId] = { ready, required: rule.requiredForProduction, reasons }; + if (rule.requiredForProduction && !ready) + blockers.push(`${providerId}: ${reasons.join("; ")}`); + } + + if (report.liveChecksPerformed !== true) + blockers.push("Live legal-source checks have not been performed."); + + return { + ready: blockers.length === 0, + blockers, + providers, + }; +} + +export function nextProviderState(rule, previous, checkSucceeded) { + const failures = checkSucceeded ? 0 : (previous.consecutiveFailures ?? 0) + 1; + const successes = checkSucceeded ? (previous.consecutiveSuccesses ?? 0) + 1 : 0; + if (!checkSucceeded && failures >= rule.quarantineAfterFailures) + return { state: "quarantined", consecutiveFailures: failures, consecutiveSuccesses: 0 }; + if (previous.state === "quarantined" && successes < rule.recoverAfterSuccesses) + return { state: "quarantined", consecutiveFailures: 0, consecutiveSuccesses: successes }; + return { + state: checkSucceeded ? "healthy" : "degraded", + consecutiveFailures: failures, + consecutiveSuccesses: successes, + }; +} diff --git a/tests/baseline/ross-production-readiness.test.mjs b/tests/baseline/ross-production-readiness.test.mjs new file mode 100644 index 0000000000..24c30f668c --- /dev/null +++ b/tests/baseline/ross-production-readiness.test.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const read = (path) => readFileSync(resolve(root, path), "utf8"); +const json = (path) => JSON.parse(read(path)); + +test("production records remain explicitly blocked until evidence is supplied", () => { + const approvals = json("config/release-approvals.v1.json"); + const operations = json("config/operations-readiness.v1.json"); + const launch = json("config/launch-readiness.v1.json"); + assert.match(approvals.status, /^blocked-/); + assert.match(operations.status, /^blocked-/); + assert.match(launch.status, /^blocked-/); + for (const item of [ + ...Object.values(approvals.approvals), + ...Object.values(operations.evidence), + ...Object.values(launch.decisions), + ]) { + assert.equal(item.status, "pending"); + assert.equal(item.approver, null); + assert.equal(item.evidence, null); + } +}); + +test("release manifest governs code, schema, evaluation, sources, workflows, and approvals", () => { + const manifest = json("reports/release-manifest-v1.json"); + const paths = new Set(manifest.artifacts.map((item) => item.path)); + for (const path of [ + "backend/schema.sql", + "config/legal-source-operations.v1.json", + "config/launch-readiness.v1.json", + "config/operations-readiness.v1.json", + "config/release-approvals.v1.json", + "reports/legal-source-health-v1.json", + "reports/ontario-evaluation-v1.json", + "workflows/ontario/catalogue.json", + ]) + assert.equal(paths.has(path), true, path); + assert.equal(manifest.algorithm, "sha256"); + assert.equal(manifest.artifactCount, manifest.artifacts.length); +}); + +test("release candidate workflow creates evidence but never deploys", () => { + const workflow = read(".github/workflows/release-candidate.yml"); + assert.match(workflow, /workflow_dispatch/); + assert.match(workflow, /npm run check/); + assert.match(workflow, /upload-artifact@v4/); + assert.doesNotMatch(workflow, /\bdeploy(?:ment)?\s*:/i); + assert.doesNotMatch(workflow, /wrangler deploy|kubectl|helm upgrade/i); +}); + +test("production operations are documented without expanding the beta data boundary", () => { + for (const path of [ + "docs/operations/release-runbook.md", + "docs/operations/backup-restore.md", + "docs/operations/rollback.md", + "docs/operations/legal-source-operations.md", + "docs/operations/observability.md", + "docs/operations/launch-checklist.md", + "SECURITY.md", + ]) + assert.ok(read(path).length > 300, path); + assert.match(read("docs/operations/launch-checklist.md"), /synthetic\/non-confidential/); + assert.match(read("docs/operations/legal-source-operations.md"), /never authorizes scraping/i); +}); diff --git a/tests/baseline/ross-website-contract.test.mjs b/tests/baseline/ross-website-contract.test.mjs index 4759c14875..e341dc7b4b 100644 --- a/tests/baseline/ross-website-contract.test.mjs +++ b/tests/baseline/ross-website-contract.test.mjs @@ -84,6 +84,7 @@ test("the website scaffold exposes every governed public route", () => { "subprocessors", "responsible-ai", "demo", + "readiness", ]; for (const key of requiredKeys) { assert.match(content, new RegExp(`[\"']?${key}[\"']?\\s*:`), key); @@ -107,7 +108,11 @@ test("the public site communicates beta limits and does not use application data const robots = read("website/app/robots.ts"); const layout = read("website/app/layout.tsx"); + assert.match(robots, /publicLaunchApproved/); assert.match(robots, /disallow: "\/"/); - assert.match(layout, /index: false/); - assert.match(layout, /follow: false/); + assert.match(layout, /index: siteConfig\.publicLaunchApproved/); + assert.match(layout, /follow: siteConfig\.publicLaunchApproved/); + assert.match(read("website/app/site-config.ts"), /legalOperator !== "TBD"/); + assert.match(read("website/app/site-config.ts"), /production-reviewed/); + assert.match(read("website/app/site-config.ts"), /\.invalid/); }); diff --git a/tests/evaluation/release-readiness.test.mjs b/tests/evaluation/release-readiness.test.mjs index 35ef30c520..3bfdb05c34 100644 --- a/tests/evaluation/release-readiness.test.mjs +++ b/tests/evaluation/release-readiness.test.mjs @@ -4,12 +4,17 @@ import { dirname, resolve } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; import { evaluateReleaseReadiness } from "../../scripts/lib/release-readiness.mjs"; +import { evaluateSourceOperations } from "../../scripts/lib/source-operations.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const readJson = (path) => JSON.parse(readFileSync(resolve(root, path), "utf8")); const report = readJson("reports/ontario-evaluation-v1.json"); const approvals = readJson("config/release-approvals.v1.json"); +const operations = readJson("config/operations-readiness.v1.json"); +const launch = readJson("config/launch-readiness.v1.json"); +const sourcePolicy = readJson("config/legal-source-operations.v1.json"); +const sourceReport = readJson("reports/legal-source-health-v1.json"); test("passing automated gates permit development while external review stays pending", () => { const result = evaluateReleaseReadiness(report, approvals, false); @@ -18,7 +23,15 @@ test("passing automated gates permit development while external review stays pen }); test("production release fails closed while independent approvals are pending", () => { - const result = evaluateReleaseReadiness(report, approvals, true); + const result = evaluateReleaseReadiness(report, approvals, true, { + operations, + launch, + sourceOperations: evaluateSourceOperations( + sourcePolicy, + sourceReport, + new Date("2026-07-16T01:00:00Z"), + ), + }); assert.equal(result.ready, false); for (const name of result.requiredApprovals) { assert.ok( @@ -27,6 +40,9 @@ test("production release fails closed while independent approvals are pending", ); } assert.ok(result.blockers.some((blocker) => /Ontario lawyer/.test(blocker))); + assert.ok(result.blockers.some((blocker) => /Operational readiness/.test(blocker))); + assert.ok(result.blockers.some((blocker) => /Launch readiness/.test(blocker))); + assert.ok(result.blockers.some((blocker) => /legal-source health/.test(blocker))); }); test("production release requires evidence-bearing approvals and lawyer-reviewed benchmark", () => { @@ -40,13 +56,35 @@ test("production release requires evidence-bearing approvals and lawyer-reviewed item.date = "2026-07-16"; item.evidence = "reviews/example.md"; } + const approvedOperations = structuredClone(operations); + approvedOperations.status = "approved-for-release"; + for (const item of Object.values(approvedOperations.evidence)) { + item.status = "approved"; + item.approver = "Operations reviewer"; + item.date = "2026-07-16"; + item.evidence = "reviews/operations-example.md"; + } + const approvedLaunch = structuredClone(launch); + approvedLaunch.status = "approved-for-launch"; + for (const item of Object.values(approvedLaunch.decisions)) { + item.status = "approved"; + item.approver = "Launch reviewer"; + item.date = "2026-07-16"; + item.evidence = "reviews/launch-example.md"; + } + const sourceOperations = { ready: true, blockers: [], providers: {} }; + const evidence = { + operations: approvedOperations, + launch: approvedLaunch, + sourceOperations, + }; assert.equal( - evaluateReleaseReadiness(reviewedReport, approved, true).ready, + evaluateReleaseReadiness(reviewedReport, approved, true, evidence).ready, true, ); approved.approvals.security.evidence = null; assert.equal( - evaluateReleaseReadiness(reviewedReport, approved, true).ready, + evaluateReleaseReadiness(reviewedReport, approved, true, evidence).ready, false, ); }); diff --git a/tests/operations/source-operations.test.mjs b/tests/operations/source-operations.test.mjs new file mode 100644 index 0000000000..7f7cc15462 --- /dev/null +++ b/tests/operations/source-operations.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { evaluateSourceOperations, nextProviderState } from "../../scripts/lib/source-operations.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const readJson = (path) => JSON.parse(readFileSync(resolve(root, path), "utf8")); +const policy = readJson("config/legal-source-operations.v1.json"); +const report = readJson("reports/legal-source-health-v1.json"); + +test("pre-production source report is explicit and fails the production gate", () => { + const result = evaluateSourceOperations(policy, report, new Date("2026-07-16T01:00:00Z")); + assert.equal(result.ready, false); + assert.match(result.blockers.join("\n"), /Live legal-source checks/); + assert.match(result.blockers.join("\n"), /a2aj-canada/); +}); + +test("fresh healthy observations satisfy required-provider policy", () => { + const healthy = structuredClone(report); + healthy.liveChecksPerformed = true; + for (const [id, item] of Object.entries(healthy.providers)) { + if (id === "canlii-licensed") continue; + item.state = "healthy"; + item.checkedAt = "2026-07-16T12:00:00Z"; + item.lastSuccessfulAt = item.checkedAt; + item.sourceVersion = "synthetic-test-version"; + item.consecutiveSuccesses = 2; + } + assert.equal( + evaluateSourceOperations(policy, healthy, new Date("2026-07-16T13:00:00Z")).ready, + true, + ); +}); + +test("stale required sources fail closed while optional source failure is visible but non-blocking", () => { + const candidate = structuredClone(report); + candidate.liveChecksPerformed = true; + for (const item of Object.values(candidate.providers)) { + if (item.state === "disabled") continue; + item.state = "healthy"; + item.checkedAt = "2026-07-16T12:00:00Z"; + } + candidate.providers["ontario-elaws"].checkedAt = "2026-07-14T12:00:00Z"; + candidate.providers["courtlistener-us"].state = "unavailable"; + const result = evaluateSourceOperations(policy, candidate, new Date("2026-07-16T13:00:00Z")); + assert.equal(result.ready, false); + assert.match(result.blockers.join("\n"), /ontario-elaws/); + assert.doesNotMatch(result.blockers.join("\n"), /courtlistener-us/); + assert.equal(result.providers["courtlistener-us"].ready, false); +}); + +test("provider quarantine and recovery are deterministic", () => { + const rule = policy.providers["a2aj-canada"]; + let state = { state: "healthy", consecutiveFailures: 0, consecutiveSuccesses: 2 }; + state = nextProviderState(rule, state, false); + state = nextProviderState(rule, state, false); + state = nextProviderState(rule, state, false); + assert.equal(state.state, "quarantined"); + state = nextProviderState(rule, state, true); + assert.equal(state.state, "quarantined"); + state = nextProviderState(rule, state, true); + assert.equal(state.state, "healthy"); +}); diff --git a/website/.env.example b/website/.env.example index 19e659d25c..6ec897ff9a 100644 --- a/website/.env.example +++ b/website/.env.example @@ -1,2 +1,6 @@ NEXT_PUBLIC_ROSS_WEBSITE_URL=http://localhost:4173 NEXT_PUBLIC_ROSS_APP_URL=http://localhost:3000 +# Keep false until the operator, reviewed production domain, production-reviewed +# coverage, effective content, accessibility, privacy, legal, and product owners +# have all approved public indexing. +NEXT_PUBLIC_ROSS_PUBLIC_LAUNCH_APPROVED=false diff --git a/website/app/layout.tsx b/website/app/layout.tsx index f92207aa5d..436d362773 100644 --- a/website/app/layout.tsx +++ b/website/app/layout.tsx @@ -16,8 +16,15 @@ export const metadata: Metadata = { title: "ROSS — Ontario-first legal work, built in the open", description: "An open-source legal workspace in development for Ontario lawyers and paralegals.", }, - robots: { index: false, follow: false }, - other: { "codex-preview": "development" }, + robots: { + index: siteConfig.publicLaunchApproved, + follow: siteConfig.publicLaunchApproved, + }, + other: { + "codex-preview": siteConfig.publicLaunchApproved + ? "production-reviewed" + : "development", + }, icons: { icon: "/favicon.svg", shortcut: "/favicon.svg" }, }; diff --git a/website/app/page-content.ts b/website/app/page-content.ts index f30b401e37..d01beface2 100644 --- a/website/app/page-content.ts +++ b/website/app/page-content.ts @@ -322,7 +322,8 @@ export const publicPages: Record = { eyebrow: "Versioned project records", summary: "Architecture decisions, product boundaries, verification reports, and contributor guidance live with the source code.", - status: "Deployment and user documentation are still being developed.", + status: + "Release, restore, rollback, source-health, monitoring, and launch procedures are documented; live operational evidence remains pending.", governance: engineeringReviewed("ROSS technical maintainers"), sections: [ { @@ -359,6 +360,39 @@ export const publicPages: Record = { title: "Future status reporting", body: "Production status will be independently hosted and cover website, application, API, legal-source ingestion, and material incidents.", }, + { + title: "Release control", + body: "Production promotion fails closed unless legal, privacy, security, accessibility, product, Ontario benchmark, source-health, operational, and launch evidence all belong to the same immutable candidate.", + }, + ], + }, + readiness: { + title: "Launch readiness", + eyebrow: "Fail-closed release status", + summary: + "ROSS now has executable engineering gates and operational runbooks, but every production approval and live-environment exercise remains pending.", + status: + "Blocked: owner, domains, vendors, effective notices, independent reviews, live source health, staging, restore, rollback, monitoring, and go-live evidence are not approved.", + governance: independentReviewRequired( + "Release and product owners — unassigned", + ), + sections: [ + { + title: "What the code enforces", + body: "The candidate gate binds the Ontario evaluation, legal and product approvals, operational exercises, launch decisions, source health, and governed artifact hashes to a production decision. Missing evidence blocks release.", + }, + { + title: "What people must still do", + body: "Named reviewers must select and assess the operator, Canadian-region vendors, privacy and security controls, effective policies, support channels, accessibility, legal content, source coverage, benchmark, and limited beta cohort.", + }, + { + title: "Current safe boundary", + body: "The website remains owner-only and no-index. Any preview or controlled-beta use remains limited to synthetic or affirmatively non-confidential material. No confidential or privileged client-material launch is authorized.", + }, + { + title: "Promotion and rollback", + body: "A reviewed immutable artifact is promoted only after a staging journey, migration dry run, isolated backup restore, rollback exercise, monitoring test, source observation, dependency review, and incident exercise produce dated evidence.", + }, ], }, subprocessors: { @@ -451,4 +485,20 @@ export const publicUpdates = [ limitations: "No Ontario lawyer has approved the benchmark or workflows. No production operator, vendor set, confidential-data mode, comprehensive citator, or public launch has been approved.", }, + { + slug: "release-controls", + title: "Production-readiness controls remain fail-closed", + publishedAt: "2026-07-16", + status: "Engineering update — launch remains blocked", + summary: + "ROSS adds governed release artifacts, source quarantine, operational evidence, launch decisions, and indexing safeguards without claiming that external reviews or live exercises have occurred.", + changes: [ + "Added required-provider freshness, quarantine, and recovery policy with a deliberately unobserved pre-production health report.", + "Added an immutable SHA-256 release manifest and manual CI evidence workflow with no automatic production deployment.", + "Added release, backup/restore, rollback, source, monitoring, incident, and launch procedures tied to dated evidence.", + "Added a public readiness page and a launch flag that cannot enable indexing while the operator, production coverage, or domain remains a placeholder.", + ], + limitations: + "No operator, production vendor, domain, live source monitor, staging exercise, restore, rollback, penetration test, accessibility audit, Ontario lawyer review, effective legal notice, support channel, or go-live decision is approved.", + }, ] as const; diff --git a/website/app/robots.ts b/website/app/robots.ts index 80d2e604be..463b0d909d 100644 --- a/website/app/robots.ts +++ b/website/app/robots.ts @@ -1,5 +1,11 @@ import type { MetadataRoute } from "next"; +import { siteConfig } from "./site-config"; export default function robots(): MetadataRoute.Robots { - return { rules: { userAgent: "*", disallow: "/" } }; + return siteConfig.publicLaunchApproved + ? { + rules: { userAgent: "*", allow: "/" }, + sitemap: `${siteConfig.websiteUrl}/sitemap.xml`, + } + : { rules: { userAgent: "*", disallow: "/" } }; } diff --git a/website/app/site-config.ts b/website/app/site-config.ts index 877dd453c6..e3c4f51728 100644 --- a/website/app/site-config.ts +++ b/website/app/site-config.ts @@ -18,6 +18,13 @@ export const siteConfig = { ? "Engineering integrations implemented — runtime coverage and legal review remain conditional" : "Foundation only — Ontario and Canadian source integrations are not live yet", socialLinks: brand.socialLinks, + publicLaunchApproved: + process.env.NEXT_PUBLIC_ROSS_PUBLIC_LAUNCH_APPROVED === "true" && + brand.product.legalOperator !== "TBD" && + brand.policy.coverageStatus === "production-reviewed" && + !(process.env.NEXT_PUBLIC_ROSS_WEBSITE_URL ?? brand.urls.website).includes( + ".invalid", + ), } as const; export const primaryNav = [ @@ -25,6 +32,7 @@ export const primaryNav = [ { href: "/features", label: "Features" }, { href: "/workflows", label: "Workflows" }, { href: "/coverage", label: "Coverage" }, + { href: "/readiness", label: "Readiness" }, { href: "/demo", label: "Demo" }, { href: "/open-source", label: "Open source" }, ] as const; @@ -59,6 +67,7 @@ export const footerGroups = [ ["Documentation", "/docs"], ["Contact", "/contact"], ["Status", "/status"], + ["Launch readiness", "/readiness"], ], }, { diff --git a/website/tests/public-quality.test.mjs b/website/tests/public-quality.test.mjs index 885ed2818d..2f44a72410 100644 --- a/website/tests/public-quality.test.mjs +++ b/website/tests/public-quality.test.mjs @@ -39,6 +39,7 @@ test("representative public pages meet the automated semantic accessibility cont "/ontario", "/coverage", "/responsible-ai", + "/readiness", "/privacy", "/terms", "/accessibility", @@ -65,6 +66,7 @@ test("sitemap covers dynamic public content while indexing remains intentionally const sitemap = await render("/sitemap.xml"); assert.equal(sitemap.response.status, 200); assert.match(sitemap.html, /updates\/foundation/); + assert.match(sitemap.html, /updates\/release-controls/); assert.match(sitemap.html, /workflows\/factum-authority-record-cross-check/); assert.match(sitemap.html, /\/demo/); const robots = await render("/robots.txt"); @@ -93,6 +95,7 @@ test("internal content links resolve in the built site", async () => { "/status", "/subprocessors", "/responsible-ai", + "/readiness", ]; const links = new Set(); for (const seed of seeds) { diff --git a/website/tests/rendered-html.test.mjs b/website/tests/rendered-html.test.mjs index a33a508d23..0e8fb9732a 100644 --- a/website/tests/rendered-html.test.mjs +++ b/website/tests/rendered-html.test.mjs @@ -67,6 +67,7 @@ test("required public routes render without authentication", async () => { "/subprocessors", "/responsible-ai", "/demo", + "/readiness", ]; for (const route of routes) { @@ -142,6 +143,21 @@ test("the first dated update and governed metadata render", async () => { assert.match(update.html, /No Ontario lawyer has approved/); }); +test("production readiness remains candid and indexing fails closed", async () => { + const readiness = await render("/readiness"); + assert.equal(readiness.response.status, 200); + assert.match(readiness.html, /every production approval/i); + assert.match( + readiness.html, + /No confidential or privileged client-material launch/i, + ); + const update = await render("/updates/release-controls"); + assert.equal(update.response.status, 200); + assert.match(update.html, /launch remains blocked/i); + const robots = await render("/robots.txt"); + assert.match(robots.html, /Disallow: \/$/m); +}); + test("unknown routes return the custom not-found page", async () => { const { response, html } = await render("/not-a-real-ross-page"); assert.equal(response.status, 404);