From 87f2c76f34e1bc0186cda7db86657a17a8c701bd Mon Sep 17 00:00:00 2001 From: Sythos Date: Sun, 23 Aug 2026 16:39:01 +0200 Subject: [PATCH] feat: add M10 release readiness evidence --- .github/workflows/quality-gates.yml | 11 + README.md | 11 + doc/README.md | 17 +- doc/release-readiness.md | 185 +++++++++++ package.json | 3 +- release/v1-release-evidence.template.json | 93 ++++++ scripts/m10-release-audit.mjs | 23 ++ src/release/index.mjs | 18 ++ src/release/release-evidence.mjs | 372 ++++++++++++++++++++++ src/release/release-evidence.test.mjs | 131 ++++++++ 10 files changed, 856 insertions(+), 8 deletions(-) create mode 100644 doc/release-readiness.md create mode 100644 release/v1-release-evidence.template.json create mode 100644 scripts/m10-release-audit.mjs create mode 100644 src/release/index.mjs create mode 100644 src/release/release-evidence.mjs create mode 100644 src/release/release-evidence.test.mjs diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index ed16ce8..55d1954 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -75,6 +75,9 @@ jobs: test -f doc/upgrade-and-migration.md test -f doc/web-foundation.md test -f doc/dav-and-discovery.md + test -f doc/release-readiness.md + test -f release/v1-release-evidence.template.json + test -f scripts/m10-release-audit.mjs test -f tsconfig.json test -f web/index.html test -f web/styles.css @@ -144,6 +147,9 @@ jobs: test -f src/upgrade/rollout.mjs test -f src/upgrade/index.mjs test -f src/upgrade/upgrade-contract.test.mjs + test -f src/release/index.mjs + test -f src/release/release-evidence.mjs + test -f src/release/release-evidence.test.mjs test -f doc/upgrade-and-migration.md grep -Fq 'profiles: ["local"]' compose.yaml grep -Fq 'profiles: ["test"]' compose.yaml @@ -163,6 +169,7 @@ jobs: scripts/m0-smoke.ps1 \ scripts/m1-fixture-smoke.ps1 \ scripts/container-patch.sh \ + scripts/m10-release-audit.mjs \ .gitignore \ .github/workflows/commit-tests.yml \ .github/workflows/pr-validation.yml \ @@ -240,6 +247,9 @@ jobs: grep -Fq 'createDockerReplacementPlan' src/upgrade/rollout.mjs grep -Fq 'createKubernetesBlueGreenPlan' src/upgrade/rollout.mjs grep -Fq 'ACTION_NOT_ALLOWLISTED' src/upgrade/rollout.mjs + grep -Fq 'REQUIRED_SECTION30_ITEMS' src/release/release-evidence.mjs + grep -Fq 'M10_REVIEWS_INCOMPLETE' src/release/release-evidence.mjs + grep -Fq 'productionReady' src/release/release-evidence.mjs grep -Fq 'SPDX-License-Identifier: MIT' config/schema.v1.json grep -Fq 'Sythos (https://www.sythos.net)' config/schema.v1.json @@ -269,6 +279,7 @@ jobs: node -e "const p=require('./package-lock.json'); const root=p.packages && p.packages['']; if (!root || root.license !== 'MIT' || root.author !== 'Sythos (https://www.sythos.net)' || root.spdxLicenseIdentifier !== 'MIT') process.exit(1)" node -e "const s=require('./config/schema.v1.json'); if (!s.properties.buildDigest || !s.properties.patching) process.exit(1)" test -f package-lock.json + node -e "const e=require('./release/v1-release-evidence.template.json'); if (e.spdxLicenseIdentifier !== 'MIT' || e.author !== 'Sythos (https://www.sythos.net)' || e.product !== 'Gulo Gulo') process.exit(1)" - name: Set up Node.js when package metadata exists if: hashFiles('package.json') != '' diff --git a/README.md b/README.md index ec16458..e0a6db7 100644 --- a/README.md +++ b/README.md @@ -127,12 +127,19 @@ gulogulo/ │ ├── lifecycle-backup-dr.md │ ├── mail-core.md │ ├── rbac-admin-mfa.md +│ ├── release-readiness.md │ ├── observability.md │ ├── storage-and-quotas.md │ ├── upgrade-and-migration.md │ └── web-foundation.md ├── scripts/ +│ ├── m0-smoke.ps1 +│ ├── m1-fixture-smoke.ps1 +│ ├── m10-release-audit.mjs +│ ├── container-patch.sh │ └── runtime, fixture, and patch utilities +├── release/ +│ └── v1-release-evidence.template.json ├── src/ │ ├── admin/ │ ├── auth/ @@ -143,6 +150,10 @@ gulogulo/ │ ├── lifecycle/ │ ├── mail/ │ ├── observability/ +│ ├── release/ +│ │ ├── index.mjs +│ │ ├── release-evidence.mjs +│ │ └── release-evidence.test.mjs │ ├── ops/ │ │ ├── abuse/ │ │ └── acme/ diff --git a/doc/README.md b/doc/README.md index 2ec1fbc..2ad07d8 100644 --- a/doc/README.md +++ b/doc/README.md @@ -51,12 +51,15 @@ normal workflow. calendar and address-book contracts, conditional writes, ETags, sync tokens, iCalendar/vCard validation, `.well-known` responses, and safe manual configuration fallback. +- [Release readiness](release-readiness.md) — the Section 30 evidence object, + hardening review matrix, sanitized provider API/MCP read surface, and the + honest boundary between a usable contract preview and a production service. The project is still intentionally small. The documents describe real behavior -only: DAV, administration, lifecycle, backup, observability, ACME, and abuse -are deterministic contract boundaries until their external adapters and -rehearsals are in place. The M9 upgrade module adds the validation and state -machine, but production deployment still needs a persistent DAV backend, -authenticated HTTP method adapter, real ACME/DNS operations, measured RPO/RTO, -an actual Docker/Kubernetes cutover, and standard-client interoperability -rehearsal before it is treated as a complete external service. +only: DAV, administration, lifecycle, backup, observability, ACME, abuse, and +upgrade are deterministic contract boundaries until their external adapters and +rehearsals are in place. The release-readiness guide collects those limits in a +machine-checkable decision, but production deployment still needs a persistent +DAV backend, authenticated HTTP method adapter, real ACME/DNS operations, +measured RPO/RTO, an actual Docker/Kubernetes cutover, and standard-client +interoperability rehearsal before it is treated as a complete external service. diff --git a/doc/release-readiness.md b/doc/release-readiness.md new file mode 100644 index 0000000..c3977a3 --- /dev/null +++ b/doc/release-readiness.md @@ -0,0 +1,185 @@ +# Release readiness and the M10 boundary + + + +This is the slightly boring document that keeps a release honest. M10 does not +turn a fixture into a mail provider by changing the wording around it. It +packages the review into a repeatable evidence object, runs the security and +tenant-boundary checks, and makes the remaining external work visible to the +operator. + +The useful result at this point is a **V1 contract preview**: a clean checkout +can be installed, tested, inspected, and used as the application/runtime +foundation. A production mail service still needs the provider adapters and a +rehearsal against real Postfix, Dovecot, LDAP, PostgreSQL, CalDAV, CardDAV, +certificate, scanner, backup, and traffic-switching services. Those are +explicit residuals, not hidden assumptions. + +## What M10 checks + +The M10 gate covers five evidence domains: + +| Domain | What is checked in this checkout | What still needs an external rehearsal | +|---|---|---| +| Security | tenant/RBAC boundaries, session and CSRF contracts, MFA primitives, HTML sanitization, abuse limits, and secret-free audit events | provider secret rotation, real TLS/LDAP/DB configuration, image signing and SBOM publication | +| Data | quota allocation, 28-day purge, backup authorization, encrypted archive shape, idempotent lifecycle operations | an actual restore, deletion runbook execution, external-volume snapshots, measured RPO/RTO | +| Interoperability | SMTP/IMAP/IDLE, Sieve, DAV object semantics, discovery, ICS/vCard, and timezone contracts | vendor client matrix and real protocol endpoints | +| Operations | health, metrics, logging, alerts, queue visibility, Docker/Kubernetes migration contracts, and multi-architecture build configuration | scanner update channels, Docker host replacement, Kubernetes cutover, rollback, and incident tabletop | +| Governance | role and delegation policy, default-deny master access, read-only API/MCP, ADRs, and documentation inventory | owner approval of the deployment and disaster-recovery runbooks | + +The source of truth for the checklist remains Section 30 of `GULOGULO.md`. +The repository copy is deliberately an evidence boundary, not a second product +specification. + +## Running the gate + +From a clean checkout, install the locked dependencies and run the complete +suite: + +```text +npm ci --ignore-scripts --no-audit --no-fund +npm test +``` + +The focused M10 checks can be run while iterating: + +```text +npm run test:m10 +``` + +That command runs `src/release/release-evidence.test.mjs` and then audits the +portable example at `release/v1-release-evidence.template.json`. The audit +prints only a sanitized decision summary. It never prints credentials, raw +deployment output, mailbox content, or local workstation paths. + +## Evidence object + +The validator lives in `src/release/release-evidence.mjs`. Its public API is +small on purpose: + +```js +import { + createReleaseEvidence, + evaluateReleaseEvidence, + REQUIRED_SECTION30_ITEMS, +} from './src/release/release-evidence.mjs'; + +const evidence = createReleaseEvidence({ + evidenceVersion: '1.0', + product: 'Gulo Gulo', + version: '0.0.0-m10', + commitSha: '0123456789abcdef0123456789abcdef01234567', + generatedAt: '2026-08-23T00:00:00Z', + releaseDecision: 'conditional', + section30: [/* every REQUIRED_SECTION30_ITEMS entry */], + securityFindings: [], + tests: [], + artifacts: [{ + name: 'operator guide', + path: 'doc/release-readiness.md', + purpose: 'Release boundary and evidence instructions.', + }], + residualRisks: ['External service rehearsal is still required.'], + nextCandidates: ['Wire provider adapters.'], +}); + +const summary = evaluateReleaseEvidence(evidence); +``` + +`createReleaseEvidence` returns a frozen, normalized object. It enforces the +following rules before a release can be discussed: + +- all 45 applicable Section 30 item IDs must occur exactly once; +- `verified` and `contract` entries must point to repository-relative evidence; +- `deferred` and `exception` entries must name an owner, mitigation, rationale, + and dated approval; +- critical or high findings cannot remain `open`; +- failed tests block the release; +- artifacts, rationale, commands, and residual risks are scanned for secrets, + private keys, tokens, and workstation paths; +- an `approved` decision is rejected when the checklist still contains a + deferred or exception item. + +There are four checklist statuses: + +- `verified` means the local and CI evidence is sufficient for the stated + contract; +- `contract` means the behavior is specified and tested at the boundary, but a + vendor or deployment environment must still prove it; +- `deferred` means the item is intentionally left for the provider rehearsal; +- `exception` is the same kind of explicit deferral when the owner wants to + call out a formal non-conformance. + +`evaluateReleaseEvidence` reports `productionReady: false` whenever a contract, +deferred, exception, conditional test, or accepted security finding remains. +That conservative result is intentional: a green repository check is not a +certificate, a live LDAP bind, or a measured zero-downtime cutover. + +## API and MCP handling + +The tenant monitoring API and MCP stay read-only in V1. M10 adds a release +evidence vocabulary; it does not add a write-capable release endpoint. + +The provider/operator plane may expose a sanitized read operation equivalent to +the following contract: + +| Surface | Operation | Result | +|---|---|---| +| HTTP | `GET /provider/release/evidence` | current decision, checklist counts, test statuses, residual risks, and next candidates | +| MCP | `gulogulo.release.evidence` | the same read-only object, scoped to the provider deployment | +| HTTP | `GET /provider/release/capabilities` | evidence schema version and supported status values | +| MCP | `gulogulo.release.capabilities` | the same capability document | + +The response must be derived from `createReleaseEvidence` before publication. +It must contain the release version, commit identifier, Section 30 status +counts, sanitized evidence references, and correlation metadata. It must not +contain passwords, tokens, private keys, mailbox content, raw command output, +Docker socket paths, unrestricted Kubernetes arguments, or absolute local +paths. + +The tenant, master, and user audiences may read only the subset allowed by the +existing RBAC and log-visibility policy. They cannot approve an exception, +change a status, start an upgrade, or mark a live rehearsal complete. A +provider approval remains an auditable, separate operation. + +## Login and browser boundary + +The current HTML5/TypeScript shell is deliberately honest about its stage. It +uses secure-cookie and CSRF contracts, renders mail/calendar/contact views, +and refuses to treat realtime metadata or message HTML as trusted application +content. The complete authenticated `/login` route and its provider LDAP +adapter are not faked by the release audit. When that route is wired, it must +keep the Gulo Gulo artwork at the left of the login layout, scale it to +128×128, and keep the form on the right as recorded in the canonical artwork +memory. + +## External-evidence checklist + +Before calling a deployment production-ready, an operator should attach these +records to the release commit or release system: + +1. Docker `linux/amd64` and `linux/arm64` image digests, SBOM, and signature. +2. TLS/ACME issuance, renewal, expiry alert, LDAP bind, and PostgreSQL backup + evidence. +3. Postfix, Rspamd, ClamAV, Dovecot, Sieve, CalDAV, CardDAV, and autodiscovery + client results. +4. Encrypted backup, tenant/user restore, purge, and account deletion records. +5. Docker replacement and Kubernetes blue/green timings, including rollback + and connection-drain evidence. +6. Approved RPO/RTO, incident, and disaster-recovery runbooks. + +When these records exist, replace the corresponding `deferred` or `contract` +entries in a release evidence object with `verified`, use the actual release +commit SHA, and rerun `npm run test:m10` plus the GitHub Actions quality gates. + +## Future work deliberately outside M10 + +Shared mailboxes, resource calendars, write-capable tenant API/MCP, assisted +IMAP migration, and provider-specific live adapters remain next-version or +deployment work. Keeping them out of this gate makes the product smaller and +safer to review; it does not prevent adding them later behind a new contract +and a new acceptance record. diff --git a/package.json b/package.json index 0792dbf..4440e83 100644 --- a/package.json +++ b/package.json @@ -16,11 +16,12 @@ "build:web": "node web/build.mjs", "typecheck": "tsc --project tsconfig.json --noEmit", "test:web": "npm run build:web && node web/test/web-shell.test.mjs", - "test": "npm run test:web && npm run test:m6 && npm run test:m7 && npm run test:m8 && npm run test:m9 && node src/runtime/runtime.test.mjs && node src/foundation/config.test.mjs && node src/runtime/observability.test.mjs && node src/integrations/tenant-context.test.mjs && node src/integrations/ldap-client.test.mjs && node src/integrations/postgres-store.test.mjs && node src/mail/mail-core.test.mjs && node src/web/security/security.test.mjs && node src/web/content/email-content.test.mjs && node src/web/content/attachment-policy.test.mjs && node src/web/content/timezone.test.mjs && node src/web/realtime/event-normalizer.test.mjs && node src/web/backup/backup-request.test.mjs && node src/dav/caldav/caldav-contract.test.mjs && node src/dav/carddav/carddav-store.test.mjs && node src/dav/discovery/index.test.mjs", + "test": "npm run test:web && npm run test:m6 && npm run test:m7 && npm run test:m8 && npm run test:m9 && npm run test:m10 && node src/runtime/runtime.test.mjs && node src/foundation/config.test.mjs && node src/runtime/observability.test.mjs && node src/integrations/tenant-context.test.mjs && node src/integrations/ldap-client.test.mjs && node src/integrations/postgres-store.test.mjs && node src/mail/mail-core.test.mjs && node src/web/security/security.test.mjs && node src/web/content/email-content.test.mjs && node src/web/content/attachment-policy.test.mjs && node src/web/content/timezone.test.mjs && node src/web/realtime/event-normalizer.test.mjs && node src/web/backup/backup-request.test.mjs && node src/dav/caldav/caldav-contract.test.mjs && node src/dav/carddav/carddav-store.test.mjs && node src/dav/discovery/index.test.mjs", "test:m6": "node src/admin/rbac.test.mjs && node src/admin/delegation.test.mjs && node src/admin/quota.test.mjs && node src/admin/admin-tools.test.mjs && node src/auth/auth.test.mjs", "test:m7": "node src/lifecycle/retention.test.mjs && node src/lifecycle/account-lifecycle.test.mjs && node src/backup/backup-contract.test.mjs && node src/observability/observability.test.mjs", "test:m8": "node src/ops/acme/index.test.mjs && node src/ops/abuse/index.test.mjs", "test:m9": "node src/upgrade/upgrade-contract.test.mjs", + "test:m10": "node src/release/release-evidence.test.mjs && node scripts/m10-release-audit.mjs", "test:m2:postgres": "node src/integrations/postgres.integration.test.mjs" }, "dependencies": { diff --git a/release/v1-release-evidence.template.json b/release/v1-release-evidence.template.json new file mode 100644 index 0000000..9f39150 --- /dev/null +++ b/release/v1-release-evidence.template.json @@ -0,0 +1,93 @@ +{ + "spdxLicenseIdentifier": "MIT", + "spdxFileCopyrightText": "2026 Sythos (https://www.sythos.net)", + "author": "Sythos (https://www.sythos.net)", + "evidenceVersion": "1.0", + "product": "Gulo Gulo", + "version": "0.0.0-m10", + "commitSha": "0000000000000000000000000000000000000000", + "generatedAt": "2026-08-23T00:00:00Z", + "releaseDecision": "conditional", + "section30": [ + { "id": "security.no-open-relay", "status": "contract", "evidence": ["src/mail/mail-policy.mjs", "doc/release-readiness.md#security"] }, + { "id": "security.tls-certificates", "status": "contract", "evidence": ["src/ops/acme/index.mjs", "doc/acme-abuse-deployment.md"] }, + { "id": "security.acme-renewal", "status": "contract", "evidence": ["src/ops/acme/index.mjs", "doc/acme-abuse-deployment.md"] }, + { "id": "security.ldap-tls-bind", "status": "contract", "evidence": ["src/integrations/ldap-client.mjs", "doc/identity-and-postgres.md"] }, + { "id": "security.postgresql-protection", "status": "contract", "evidence": ["src/integrations/postgres-store.mjs", "doc/identity-and-postgres.md"] }, + { "id": "security.secret-store-rotation", "status": "deferred", "evidence": ["doc/release-readiness.md#security"], "owner": "Sythos", "mitigation": "Keep secrets outside the repository and complete provider secret-store rotation before production.", "rationale": "A clean checkout cannot prove the selected provider secret store.", "approval": { "approvedBy": "Sythos", "approvedAt": "2026-08-23T00:00:00Z" } }, + { "id": "security.csp-csrf-headers", "status": "verified", "evidence": ["web/index.html", "src/web/security", "doc/web-foundation.md"] }, + { "id": "security.email-html-sanitization", "status": "verified", "evidence": ["src/web/content/email-content.test.mjs", "web/src/app.ts"] }, + { "id": "security.rate-abuse-controls", "status": "verified", "evidence": ["src/ops/abuse/index.test.mjs", "doc/acme-abuse-deployment.md"] }, + { "id": "security.audit-no-secrets", "status": "verified", "evidence": ["src/observability/structured-event.mjs", "src/observability/observability.test.mjs"] }, + { "id": "security.images-sbom-digest", "status": "deferred", "evidence": ["doc/release-readiness.md#security"], "owner": "Sythos", "mitigation": "Publish signed image digests and an SBOM from the release registry before production deployment.", "rationale": "The local Docker boundary does not publish a registry artifact.", "approval": { "approvedBy": "Sythos", "approvedAt": "2026-08-23T00:00:00Z" } }, + { "id": "data.sources-of-truth", "status": "verified", "evidence": ["doc/configuration.md", "GULOGULO-MEMORY.md"] }, + { "id": "data.quota-ledger", "status": "verified", "evidence": ["src/admin/quota.test.mjs", "doc/storage-and-quotas.md"] }, + { "id": "data.retention-28-days", "status": "verified", "evidence": ["src/lifecycle/retention.test.mjs", "doc/lifecycle-backup-dr.md"] }, + { "id": "data.user-backup-authorization", "status": "verified", "evidence": ["src/web/backup/backup-request.test.mjs", "doc/lifecycle-backup-dr.md"] }, + { "id": "data.provider-backup-encryption", "status": "contract", "evidence": ["src/backup/backup-contract.mjs", "doc/lifecycle-backup-dr.md"] }, + { "id": "data.restore", "status": "deferred", "evidence": ["src/backup/backup-contract.test.mjs", "doc/release-readiness.md#data"], "owner": "Sythos", "mitigation": "Run and attach a provider restore rehearsal covering tenant, user, mailbox, DAV, and configuration scopes.", "rationale": "The repository verifies archive semantics but has no external backup target.", "approval": { "approvedBy": "Sythos", "approvedAt": "2026-08-23T00:00:00Z" } }, + { "id": "data.purge-idempotent", "status": "verified", "evidence": ["src/lifecycle/retention.test.mjs", "doc/lifecycle-backup-dr.md"] }, + { "id": "data.account-deletion-runbook", "status": "deferred", "evidence": ["doc/lifecycle-backup-dr.md", "doc/release-readiness.md#data"], "owner": "Sythos", "mitigation": "Approve and rehearse the account deletion runbook against external LDAP, PostgreSQL, mail, and DAV stores.", "rationale": "External source-of-truth deletion cannot be executed by the contract suite.", "approval": { "approvedBy": "Sythos", "approvedAt": "2026-08-23T00:00:00Z" } }, + { "id": "interop.smtp-imap", "status": "contract", "evidence": ["src/mail/mail-policy.mjs", "doc/mail-core.md"] }, + { "id": "interop.imap-idle", "status": "contract", "evidence": ["src/mail/imap-idle.mjs", "doc/mail-core.md"] }, + { "id": "interop.sieve", "status": "contract", "evidence": ["doc/mail-core.md", "GULOGULO.md#30"] }, + { "id": "interop.aliases", "status": "verified", "evidence": ["src/mail/mail-policy.mjs", "doc/mail-core.md"] }, + { "id": "interop.caldav", "status": "contract", "evidence": ["src/dav/caldav/caldav-contract.test.mjs", "doc/dav-and-discovery.md"] }, + { "id": "interop.carddav", "status": "contract", "evidence": ["src/dav/carddav/carddav-store.test.mjs", "doc/dav-and-discovery.md"] }, + { "id": "interop.well-known", "status": "verified", "evidence": ["src/dav/discovery/index.test.mjs", "doc/dav-and-discovery.md"] }, + { "id": "interop.autodiscovery", "status": "contract", "evidence": ["src/dav/discovery/index.test.mjs", "doc/dav-and-discovery.md"] }, + { "id": "interop.ics-vcard", "status": "verified", "evidence": ["src/dav/caldav/caldav-contract.test.mjs", "src/dav/carddav/carddav-store.test.mjs"] }, + { "id": "interop.timezone", "status": "verified", "evidence": ["src/web/content/timezone.test.mjs", "web/src/app.ts"] }, + { "id": "operations.health-metrics", "status": "verified", "evidence": ["src/runtime/runtime.test.mjs", "src/runtime/observability.test.mjs"] }, + { "id": "operations.multi-architecture-images", "status": "contract", "evidence": ["Dockerfile", "doc/compose-and-fixtures.md"] }, + { "id": "operations.log-rotation", "status": "verified", "evidence": ["src/observability/log-policy.mjs", "doc/observability.md"] }, + { "id": "operations.alerts", "status": "verified", "evidence": ["src/observability/alert-policy.mjs", "doc/observability.md"] }, + { "id": "operations.postfix-queue", "status": "contract", "evidence": ["src/admin/admin-tools.mjs", "doc/mail-core.md"] }, + { "id": "operations.rspamd-clamav-updates", "status": "deferred", "evidence": ["src/mail/mail-scanners.mjs", "doc/release-readiness.md#operations"], "owner": "Sythos", "mitigation": "Run scanner update and fail-closed delivery rehearsals with the provider image and monitoring stack.", "rationale": "Vendor scanner containers and their update channels are external to this checkout.", "approval": { "approvedBy": "Sythos", "approvedAt": "2026-08-23T00:00:00Z" } }, + { "id": "operations.migration-contract", "status": "verified", "evidence": ["src/upgrade/upgrade-contract.test.mjs", "doc/upgrade-and-migration.md"] }, + { "id": "operations.blue-green-rehearsal", "status": "deferred", "evidence": ["src/upgrade/upgrade-contract.test.mjs", "doc/upgrade-and-migration.md"], "owner": "Sythos", "mitigation": "Rehearse Docker replacement and Kubernetes blue/green cutover with real external state and record timings.", "rationale": "No Docker host or Kubernetes cluster is available to the contract suite.", "approval": { "approvedBy": "Sythos", "approvedAt": "2026-08-23T00:00:00Z" } }, + { "id": "operations.rollback-rehearsal", "status": "deferred", "evidence": ["src/upgrade/upgrade-contract.test.mjs", "doc/upgrade-and-migration.md"], "owner": "Sythos", "mitigation": "Exercise rollback after a controlled green failure and retain the sanitized operation evidence.", "rationale": "Rollback execution belongs to the provider control plane and external deployment environment.", "approval": { "approvedBy": "Sythos", "approvedAt": "2026-08-23T00:00:00Z" } }, + { "id": "operations.rpo-rto", "status": "deferred", "evidence": ["doc/lifecycle-backup-dr.md", "doc/release-readiness.md#operations"], "owner": "Sythos", "mitigation": "Approve measured RPO/RTO objectives after a restore and cutover rehearsal using production-like data volumes.", "rationale": "A deterministic repository test cannot measure external storage and network recovery time.", "approval": { "approvedBy": "Sythos", "approvedAt": "2026-08-23T00:00:00Z" } }, + { "id": "operations.incident-dr", "status": "deferred", "evidence": ["doc/lifecycle-backup-dr.md", "doc/release-readiness.md#operations"], "owner": "Sythos", "mitigation": "Approve the incident, backup, restore, and disaster-recovery runbooks after an operator tabletop exercise.", "rationale": "The repository can document the procedure but cannot validate human response and external escalation.", "approval": { "approvedBy": "Sythos", "approvedAt": "2026-08-23T00:00:00Z" } }, + { "id": "governance.roles-delegation", "status": "verified", "evidence": ["src/admin/rbac.test.mjs", "src/admin/delegation.test.mjs", "doc/rbac-admin-mfa.md"] }, + { "id": "governance.master-log-access", "status": "verified", "evidence": ["src/admin/rbac.test.mjs", "doc/rbac-admin-mfa.md"] }, + { "id": "governance.api-mcp-readonly", "status": "verified", "evidence": ["doc/api-and-mcp.md", "src/admin/admin-tools.test.mjs"] }, + { "id": "governance.future-features", "status": "verified", "evidence": ["GULOGULO.md#30", "doc/release-readiness.md#future"] }, + { "id": "governance.adrs-current", "status": "verified", "evidence": ["ADR-001-gulogulo-runtime-and-frontend-architecture.md", "doc/README.md"] }, + { "id": "governance.deployment-docs", "status": "contract", "evidence": ["doc/README.md", "doc/compose-and-fixtures.md", "doc/release-readiness.md"] } + ], + "reviews": [ + { "id": "threat-model", "status": "passed", "evidence": ["doc/release-readiness.md#security", "src/admin/rbac.test.mjs"] }, + { "id": "tenant-isolation", "status": "passed", "evidence": ["src/admin/rbac.test.mjs", "src/integrations/tenant-context.test.mjs"] }, + { "id": "auth-and-abuse", "status": "passed", "evidence": ["src/auth/auth.test.mjs", "src/ops/abuse/index.test.mjs"] }, + { "id": "accessibility", "status": "passed", "evidence": ["web/test/web-shell.test.mjs", "doc/web-foundation.md"] }, + { "id": "performance-capacity", "status": "conditional", "evidence": ["src/runtime/observability.test.mjs", "doc/release-readiness.md#external-evidence"], "owner": "Sythos", "mitigation": "Measure latency, memory, queue depth, and capacity with production-like tenants before production.", "approval": { "approvedBy": "Sythos", "approvedAt": "2026-08-23T00:00:00Z" } }, + { "id": "mail-interoperability", "status": "conditional", "evidence": ["doc/mail-core.md", "src/mail/mail-core.test.mjs"], "owner": "Sythos", "mitigation": "Run the vendor SMTP, IMAP, IDLE, Sieve, Rspamd, ClamAV, and Postfix matrix before production.", "approval": { "approvedBy": "Sythos", "approvedAt": "2026-08-23T00:00:00Z" } }, + { "id": "dav-interoperability", "status": "conditional", "evidence": ["doc/dav-and-discovery.md", "src/dav/discovery/index.test.mjs"], "owner": "Sythos", "mitigation": "Run CalDAV, CardDAV, ICS, vCard, and autodiscovery clients against the provider endpoints.", "approval": { "approvedBy": "Sythos", "approvedAt": "2026-08-23T00:00:00Z" } }, + { "id": "backup-restore", "status": "deferred", "evidence": ["doc/lifecycle-backup-dr.md", "src/backup/backup-contract.test.mjs"], "owner": "Sythos", "mitigation": "Rehearse encrypted backup and restore for every supported scope and attach measured RPO/RTO.", "approval": { "approvedBy": "Sythos", "approvedAt": "2026-08-23T00:00:00Z" } }, + { "id": "blue-green-upgrade", "status": "deferred", "evidence": ["doc/upgrade-and-migration.md", "src/upgrade/upgrade-contract.test.mjs"], "owner": "Sythos", "mitigation": "Run Docker and Kubernetes green cutover, drain, rollback, and finalize with external state.", "approval": { "approvedBy": "Sythos", "approvedAt": "2026-08-23T00:00:00Z" } }, + { "id": "documentation-consistency", "status": "passed", "evidence": ["doc/release-readiness.md", "README.md", "GULOGULO.md#30"] } + ], + "securityFindings": [], + "tests": [ + { "name": "M10 contract suite", "command": "npm run test:m10", "status": "passed", "evidence": "src/release/release-evidence.test.mjs" }, + { "name": "full Node test suite", "command": "npm test", "status": "passed", "evidence": "github-actions/quality-gates" }, + { "name": "Docker and external-service rehearsal", "command": "provider deployment pipeline", "status": "conditional", "evidence": "doc/release-readiness.md#external-evidence" } + ], + "artifacts": [ + { "name": "release readiness guide", "path": "doc/release-readiness.md", "purpose": "Operator-facing M10 evidence and release boundary." }, + { "name": "release evidence contract", "path": "src/release/release-evidence.mjs", "purpose": "Deterministic validation of Section 30 evidence." }, + { "name": "upgrade runbook", "path": "doc/upgrade-and-migration.md", "purpose": "Provider-only Docker and Kubernetes migration contract." }, + { "name": "configuration schema", "path": "config/schema.v1.json", "purpose": "Reproducible configuration shape." } + ], + "residualRisks": [ + "Live Postfix, Dovecot, LDAP, PostgreSQL, CalDAV, and CardDAV adapters still need an approved deployment rehearsal.", + "Registry image signing, SBOM publication, restore timing, and zero-downtime traffic switching remain provider evidence.", + "The web shell is a protected-contract foundation; a complete authenticated browser route still belongs to the adapter phase." + ], + "nextCandidates": [ + "Wire provider adapters and the authenticated login route.", + "Run interoperability, restore, and blue/green rehearsals with production-like external services.", + "Publish signed multi-architecture images and an evidence-bearing release tag." + ] +} diff --git a/scripts/m10-release-audit.mjs b/scripts/m10-release-audit.mjs new file mode 100644 index 0000000..fdacefa --- /dev/null +++ b/scripts/m10-release-audit.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { createReleaseEvidence, evaluateReleaseEvidence } from '../src/release/release-evidence.mjs'; + +const inputPath = process.argv[2] ?? 'release/v1-release-evidence.template.json'; +const evidence = JSON.parse(await readFile(resolve(process.cwd(), inputPath), 'utf8')); +const normalized = createReleaseEvidence(evidence); +const evaluation = evaluateReleaseEvidence(normalized); + +process.stdout.write(`${JSON.stringify({ + product: normalized.product, + version: normalized.version, + releaseDecision: evaluation.releaseDecision, + productionReady: evaluation.productionReady, + section30Complete: evaluation.section30Complete, + section30StatusCounts: evaluation.section30StatusCounts, + conditional: evaluation.conditional, +}, null, 2)}\n`); diff --git a/src/release/index.mjs b/src/release/index.mjs new file mode 100644 index 0000000..ff9ea4e --- /dev/null +++ b/src/release/index.mjs @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +export { + EVIDENCE_STATUSES, + RELEASE_DECISIONS, + RELEASE_EVIDENCE_VERSION, + REQUIRED_SECTION30_ITEMS, + SECTION30_DOMAINS, + SECURITY_FINDING_STATUSES, + SECURITY_SEVERITIES, + REQUIRED_REVIEWS, + REVIEW_STATUSES, + TEST_STATUSES, + createReleaseEvidence, + evaluateReleaseEvidence, +} from './release-evidence.mjs'; diff --git a/src/release/release-evidence.mjs b/src/release/release-evidence.mjs new file mode 100644 index 0000000..3a6085e --- /dev/null +++ b/src/release/release-evidence.mjs @@ -0,0 +1,372 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +/** + * M10 release-evidence contract. + * + * This module turns the final review into data that can be checked by a clean + * checkout and by CI. It does not manufacture live infrastructure evidence: + * an entry can be verified, contract-tested, or deliberately deferred with an + * owner, mitigation, and approval. That distinction is what keeps a useful + * V1 contract preview honest about the work still needed before production. + */ + +const RELEASE_EVIDENCE_VERSION = '1.0'; + +const EVIDENCE_STATUSES = Object.freeze([ + 'verified', + 'contract', + 'deferred', + 'exception', +]); + +const SECURITY_SEVERITIES = Object.freeze(['critical', 'high', 'medium', 'low']); +const SECURITY_FINDING_STATUSES = Object.freeze(['resolved', 'accepted', 'open']); +const RELEASE_DECISIONS = Object.freeze(['approved', 'conditional', 'blocked']); +const TEST_STATUSES = Object.freeze(['passed', 'conditional', 'not_run', 'failed']); +const REVIEW_STATUSES = Object.freeze(['passed', 'conditional', 'deferred']); + +const REQUIRED_REVIEWS = Object.freeze([ + 'threat-model', + 'tenant-isolation', + 'auth-and-abuse', + 'accessibility', + 'performance-capacity', + 'mail-interoperability', + 'dav-interoperability', + 'backup-restore', + 'blue-green-upgrade', + 'documentation-consistency', +]); + +const REQUIRED_SECTION30_ITEMS = Object.freeze([ + 'security.no-open-relay', + 'security.tls-certificates', + 'security.acme-renewal', + 'security.ldap-tls-bind', + 'security.postgresql-protection', + 'security.secret-store-rotation', + 'security.csp-csrf-headers', + 'security.email-html-sanitization', + 'security.rate-abuse-controls', + 'security.audit-no-secrets', + 'security.images-sbom-digest', + 'data.sources-of-truth', + 'data.quota-ledger', + 'data.retention-28-days', + 'data.user-backup-authorization', + 'data.provider-backup-encryption', + 'data.restore', + 'data.purge-idempotent', + 'data.account-deletion-runbook', + 'interop.smtp-imap', + 'interop.imap-idle', + 'interop.sieve', + 'interop.aliases', + 'interop.caldav', + 'interop.carddav', + 'interop.well-known', + 'interop.autodiscovery', + 'interop.ics-vcard', + 'interop.timezone', + 'operations.health-metrics', + 'operations.multi-architecture-images', + 'operations.log-rotation', + 'operations.alerts', + 'operations.postfix-queue', + 'operations.rspamd-clamav-updates', + 'operations.migration-contract', + 'operations.blue-green-rehearsal', + 'operations.rollback-rehearsal', + 'operations.rpo-rto', + 'operations.incident-dr', + 'governance.roles-delegation', + 'governance.master-log-access', + 'governance.api-mcp-readonly', + 'governance.future-features', + 'governance.adrs-current', + 'governance.deployment-docs', +]); + +const SECTION30_DOMAINS = Object.freeze(['security', 'data', 'interop', 'operations', 'governance']); + +const LOCAL_PATH_PATTERN = /(?:^[A-Za-z]:[\\/]|^\\\\|^\/Users\/|^\/home\/|^\/private\/|F:\\Dev\\GuloGulo|C:\\Users\\)/i; +const SECRET_PATTERN = /(?:-----BEGIN .*PRIVATE KEY-----|(?:password|passwd|secret|token|api[_-]?key|private[_-]?key)\s*[:=]|(?:ghp|github_pat|sk)-[A-Za-z0-9_-]{12,})/i; + +function releaseError(message, code = 'INVALID_RELEASE_EVIDENCE') { + const error = new Error(message); + error.code = code; + return error; +} + +function assertPlainObject(value, field) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw releaseError(`${field} must be an object`, 'INVALID_RELEASE_SHAPE'); + } +} + +function assertString(value, field, { min = 1, max = 256 } = {}) { + if (typeof value !== 'string' || value.length < min || value.length > max || value.trim() !== value) { + throw releaseError(`${field} must be a trimmed string of ${min}-${max} characters`, 'INVALID_RELEASE_FIELD'); + } + return value; +} + +function assertDate(value, field) { + const date = assertString(value, field, { max: 64 }); + if (Number.isNaN(Date.parse(date))) { + throw releaseError(`${field} must be an ISO-8601 date`, 'INVALID_RELEASE_DATE'); + } + return date; +} + +function assertSafeText(value, field) { + const text = assertString(value, field, { max: 2048 }); + if (LOCAL_PATH_PATTERN.test(text) || SECRET_PATTERN.test(text)) { + throw releaseError(`${field} contains a local path or secret-like value`, 'UNSAFE_RELEASE_EVIDENCE'); + } + return text; +} + +function assertSafeReference(value, field) { + const reference = assertSafeText(value, field); + if (reference.includes('\\') || reference.split('/').includes('..')) { + throw releaseError(`${field} must be a repository-relative reference`, 'UNSAFE_RELEASE_REFERENCE'); + } + return reference; +} + +function assertEnum(value, field, allowed) { + if (!allowed.includes(value)) { + throw releaseError(`${field} must be one of: ${allowed.join(', ')}`, 'INVALID_RELEASE_ENUM'); + } + return value; +} + +function normalizeApproval(value, field) { + assertPlainObject(value, field); + return Object.freeze({ + approvedBy: assertSafeText(value.approvedBy, `${field}.approvedBy`), + approvedAt: assertDate(value.approvedAt, `${field}.approvedAt`), + }); +} + +function normalizeChecklistEntry(entry, index) { + const field = `section30[${index}]`; + assertPlainObject(entry, field); + const id = assertString(entry.id, `${field}.id`, { max: 96 }); + if (!REQUIRED_SECTION30_ITEMS.includes(id)) { + throw releaseError(`${field}.id is not an applicable Section 30 item`, 'UNKNOWN_SECTION30_ITEM'); + } + const status = assertEnum(entry.status, `${field}.status`, EVIDENCE_STATUSES); + const evidence = Array.isArray(entry.evidence) + ? entry.evidence.map((value, evidenceIndex) => assertSafeReference(value, `${field}.evidence[${evidenceIndex}]`)) + : []; + const normalized = { + id, + status, + evidence: Object.freeze(evidence), + }; + + if (status === 'verified' || status === 'contract') { + if (evidence.length === 0) { + throw releaseError(`${field} needs at least one evidence reference`, 'MISSING_SECTION30_EVIDENCE'); + } + } else { + normalized.owner = assertSafeText(entry.owner, `${field}.owner`, { max: 128 }); + normalized.mitigation = assertSafeText(entry.mitigation, `${field}.mitigation`); + normalized.rationale = assertSafeText(entry.rationale, `${field}.rationale`); + normalized.approval = normalizeApproval(entry.approval, `${field}.approval`); + } + return Object.freeze(normalized); +} + +function normalizeSecurityFinding(finding, index) { + const field = `securityFindings[${index}]`; + assertPlainObject(finding, field); + const severity = assertEnum(finding.severity, `${field}.severity`, SECURITY_SEVERITIES); + const status = assertEnum(finding.status, `${field}.status`, SECURITY_FINDING_STATUSES); + if (status === 'open' && (severity === 'critical' || severity === 'high')) { + throw releaseError(`${field} leaves a critical or high finding open`, 'UNRESOLVED_SECURITY_FINDING'); + } + const normalized = { + id: assertString(finding.id, `${field}.id`, { max: 96 }), + title: assertSafeText(finding.title, `${field}.title`), + severity, + status, + }; + if (status !== 'resolved') { + normalized.owner = assertSafeText(finding.owner, `${field}.owner`, { max: 128 }); + normalized.mitigation = assertSafeText(finding.mitigation, `${field}.mitigation`); + normalized.approval = normalizeApproval(finding.approval, `${field}.approval`); + } + return Object.freeze(normalized); +} + +function normalizeTestEvidence(entry, index) { + const field = `tests[${index}]`; + assertPlainObject(entry, field); + return Object.freeze({ + name: assertString(entry.name, `${field}.name`, { max: 128 }), + command: assertSafeText(entry.command, `${field}.command`), + status: assertEnum(entry.status, `${field}.status`, TEST_STATUSES), + evidence: assertSafeReference(entry.evidence, `${field}.evidence`), + }); +} + +function normalizeReview(entry, index) { + const field = `reviews[${index}]`; + assertPlainObject(entry, field); + const status = assertEnum(entry.status, `${field}.status`, REVIEW_STATUSES); + const normalized = { + id: assertString(entry.id, `${field}.id`, { max: 96 }), + status, + evidence: Object.freeze((Array.isArray(entry.evidence) ? entry.evidence : []) + .map((value, evidenceIndex) => assertSafeReference(value, `${field}.evidence[${evidenceIndex}]`))), + }; + if (normalized.evidence.length === 0) { + throw releaseError(`${field} needs at least one evidence reference`, 'MISSING_REVIEW_EVIDENCE'); + } + if (status !== 'passed') { + normalized.owner = assertSafeText(entry.owner, `${field}.owner`, { max: 128 }); + normalized.mitigation = assertSafeText(entry.mitigation, `${field}.mitigation`); + normalized.approval = normalizeApproval(entry.approval, `${field}.approval`); + } + return Object.freeze(normalized); +} + +function normalizeReviews(entries) { + if (!Array.isArray(entries)) { + throw releaseError('reviews must be an array', 'INVALID_RELEASE_SHAPE'); + } + const normalized = entries.map(normalizeReview); + const ids = new Set(); + for (const review of normalized) { + if (!REQUIRED_REVIEWS.includes(review.id)) { + throw releaseError(`${review.id} is not a required M10 review`, 'UNKNOWN_M10_REVIEW'); + } + if (ids.has(review.id)) throw releaseError(`reviews contains duplicate item ${review.id}`, 'DUPLICATE_M10_REVIEW'); + ids.add(review.id); + } + const missing = REQUIRED_REVIEWS.filter((id) => !ids.has(id)); + if (missing.length > 0) { + throw releaseError(`reviews is missing: ${missing.join(', ')}`, 'M10_REVIEWS_INCOMPLETE'); + } + return Object.freeze(normalized); +} + +function normalizeArtifacts(artifacts) { + if (!Array.isArray(artifacts) || artifacts.length === 0) { + throw releaseError('artifacts must contain at least one repository-relative reference', 'MISSING_RELEASE_ARTIFACTS'); + } + return Object.freeze(artifacts.map((artifact, index) => { + const field = `artifacts[${index}]`; + assertPlainObject(artifact, field); + return Object.freeze({ + name: assertString(artifact.name, `${field}.name`, { max: 128 }), + path: assertSafeReference(artifact.path, `${field}.path`), + purpose: assertSafeText(artifact.purpose, `${field}.purpose`), + }); + })); +} + +function normalizeSection30(entries) { + if (!Array.isArray(entries)) { + throw releaseError('section30 must be an array', 'INVALID_RELEASE_SHAPE'); + } + const normalized = entries.map(normalizeChecklistEntry); + const ids = new Set(); + for (const entry of normalized) { + if (ids.has(entry.id)) throw releaseError(`section30 contains duplicate item ${entry.id}`, 'DUPLICATE_SECTION30_ITEM'); + ids.add(entry.id); + } + const missing = REQUIRED_SECTION30_ITEMS.filter((id) => !ids.has(id)); + if (missing.length > 0) { + throw releaseError(`section30 is missing: ${missing.join(', ')}`, 'SECTION30_INCOMPLETE'); + } + return Object.freeze(normalized); +} + +function countStatuses(entries) { + return Object.freeze(EVIDENCE_STATUSES.reduce((counts, status) => { + counts[status] = entries.filter((entry) => entry.status === status).length; + return counts; + }, {})); +} + +function createReleaseEvidence(input = {}) { + assertPlainObject(input, 'releaseEvidence'); + const section30 = normalizeSection30(input.section30); + const reviews = normalizeReviews(input.reviews); + const securityFindings = Object.freeze((input.securityFindings ?? []).map(normalizeSecurityFinding)); + const tests = Object.freeze((input.tests ?? []).map(normalizeTestEvidence)); + const decision = assertEnum(input.releaseDecision, 'releaseDecision', RELEASE_DECISIONS); + const normalized = { + evidenceVersion: assertString(input.evidenceVersion ?? RELEASE_EVIDENCE_VERSION, 'evidenceVersion', { max: 16 }), + product: assertString(input.product ?? 'Gulo Gulo', 'product', { max: 64 }), + version: assertString(input.version, 'version', { max: 64 }), + commitSha: assertString(input.commitSha, 'commitSha', { min: 40, max: 64 }), + generatedAt: assertDate(input.generatedAt, 'generatedAt'), + releaseDecision: decision, + section30, + section30StatusCounts: countStatuses(section30), + reviews, + securityFindings, + tests, + artifacts: normalizeArtifacts(input.artifacts), + residualRisks: Object.freeze((input.residualRisks ?? []).map((value, index) => assertSafeText(value, `residualRisks[${index}]`))), + nextCandidates: Object.freeze((input.nextCandidates ?? []).map((value, index) => assertSafeText(value, `nextCandidates[${index}]`))), + }; + + if (normalized.product !== 'Gulo Gulo') { + throw releaseError('product must be Gulo Gulo', 'INVALID_RELEASE_PRODUCT'); + } + if (!/^[0-9a-f]{40,64}$/i.test(normalized.commitSha)) { + throw releaseError('commitSha must be a hexadecimal Git object identifier', 'INVALID_RELEASE_COMMIT'); + } + if (normalized.tests.some((entry) => entry.status === 'failed')) { + throw releaseError('failed test evidence blocks a release decision', 'FAILED_RELEASE_TEST'); + } + if (decision === 'approved' && section30.some((entry) => entry.status === 'deferred' || entry.status === 'exception')) { + throw releaseError('an approved release cannot contain deferred or exception checklist items', 'APPROVAL_CONFLICT'); + } + return Object.freeze(normalized); +} + +function evaluateReleaseEvidence(evidence) { + const normalized = createReleaseEvidence(evidence); + const contractOnly = normalized.section30.some((entry) => entry.status !== 'verified'); + const conditionalTests = normalized.tests.some((entry) => entry.status !== 'passed'); + const conditionalReviews = normalized.reviews.some((entry) => entry.status !== 'passed'); + const acceptedFindings = normalized.securityFindings.some((entry) => entry.status === 'accepted'); + const productionReady = normalized.releaseDecision === 'approved' + && !contractOnly + && !conditionalTests + && !conditionalReviews + && !acceptedFindings; + return Object.freeze({ + releaseDecision: normalized.releaseDecision, + productionReady, + section30Complete: true, + section30StatusCounts: normalized.section30StatusCounts, + conditional: contractOnly || conditionalTests || conditionalReviews || acceptedFindings, + unresolvedCriticalHigh: normalized.securityFindings.filter((entry) => + (entry.severity === 'critical' || entry.severity === 'high') && entry.status === 'open'), + }); +} + +export { + EVIDENCE_STATUSES, + RELEASE_DECISIONS, + RELEASE_EVIDENCE_VERSION, + REQUIRED_SECTION30_ITEMS, + SECTION30_DOMAINS, + SECURITY_FINDING_STATUSES, + SECURITY_SEVERITIES, + REQUIRED_REVIEWS, + REVIEW_STATUSES, + TEST_STATUSES, + createReleaseEvidence, + evaluateReleaseEvidence, +}; diff --git a/src/release/release-evidence.test.mjs b/src/release/release-evidence.test.mjs new file mode 100644 index 0000000..e121db7 --- /dev/null +++ b/src/release/release-evidence.test.mjs @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + REQUIRED_REVIEWS, + REQUIRED_SECTION30_ITEMS, + createReleaseEvidence, + evaluateReleaseEvidence, +} from './release-evidence.mjs'; + +const COMMIT = 'a'.repeat(40); + +function section30(statusOverrides = {}) { + return REQUIRED_SECTION30_ITEMS.map((id) => ({ + id, + status: statusOverrides[id] ?? 'verified', + evidence: [`doc/release-readiness.md#${id}`], + ...(statusOverrides[id] === 'deferred' ? { + owner: 'Sythos', + mitigation: 'Keep the deployment in contract-preview until the external rehearsal is recorded.', + rationale: 'The local checkout cannot operate a live external service.', + approval: { approvedBy: 'Sythos', approvedAt: '2026-08-23T00:00:00Z' }, + } : {}), + })); +} + +function validEvidence(overrides = {}) { + return { + evidenceVersion: '1.0', + product: 'Gulo Gulo', + version: '0.0.0-m10', + commitSha: COMMIT, + generatedAt: '2026-08-23T00:00:00Z', + releaseDecision: 'conditional', + section30: section30({ + 'operations.blue-green-rehearsal': 'deferred', + ...overrides.section30, + }), + reviews: REQUIRED_REVIEWS.map((id) => ({ + id, + status: id === 'blue-green-upgrade' ? 'deferred' : 'passed', + evidence: [`doc/release-readiness.md#${id}`], + ...(id === 'blue-green-upgrade' ? { + owner: 'Sythos', + mitigation: 'Keep the release conditional until a provider cutover rehearsal is recorded.', + approval: { approvedBy: 'Sythos', approvedAt: '2026-08-23T00:00:00Z' }, + } : {}), + })), + securityFindings: [], + tests: [{ name: 'M10 contract suite', command: 'npm run test:m10', status: 'passed', evidence: 'src/release/release-evidence.test.mjs' }], + artifacts: [{ name: 'operator guide', path: 'doc/release-readiness.md', purpose: 'Release evidence and operator boundary.' }], + residualRisks: ['Live vendor interoperability and cutover still require an approved deployment environment.'], + nextCandidates: ['Wire the provider adapters and rehearse a real blue/green cutover.'], + ...overrides, + }; +} + +test('M10 evidence requires every applicable Section 30 item', () => { + assert.equal(REQUIRED_SECTION30_ITEMS.length, 46); + assert.throws( + () => createReleaseEvidence({ ...validEvidence(), section30: section30().slice(1) }), + (error) => error.code === 'SECTION30_INCOMPLETE', + ); +}); + +test('M10 evidence requires every hardening review', () => { + assert.equal(REQUIRED_REVIEWS.length, 10); + assert.throws( + () => createReleaseEvidence({ ...validEvidence(), reviews: validEvidence().reviews.slice(1) }), + (error) => error.code === 'M10_REVIEWS_INCOMPLETE', + ); +}); + +test('deferred evidence requires an approved owner and mitigation', () => { + const evidence = createReleaseEvidence(validEvidence()); + const result = evaluateReleaseEvidence(evidence); + assert.equal(result.section30Complete, true); + assert.equal(result.productionReady, false); + assert.equal(result.conditional, true); + assert.equal(result.section30StatusCounts.deferred, 1); + assert.throws( + () => createReleaseEvidence({ + ...validEvidence(), + section30: section30({ 'operations.blue-green-rehearsal': 'deferred' }).map((entry) => + entry.id === 'operations.blue-green-rehearsal' ? { ...entry, approval: undefined } : entry), + }), + (error) => error.code === 'INVALID_RELEASE_SHAPE', + ); +}); + +test('critical and high findings cannot remain open', () => { + assert.throws( + () => createReleaseEvidence({ + ...validEvidence(), + securityFindings: [{ id: 'SEC-001', title: 'Open high finding', severity: 'high', status: 'open' }], + }), + (error) => error.code === 'UNRESOLVED_SECURITY_FINDING', + ); + + const accepted = createReleaseEvidence({ + ...validEvidence(), + securityFindings: [{ + id: 'SEC-002', + title: 'Accepted medium residual', + severity: 'medium', + status: 'accepted', + owner: 'Sythos', + mitigation: 'Keep the external adapter disabled until its review is complete.', + approval: { approvedBy: 'Sythos', approvedAt: '2026-08-23T00:00:00Z' }, + }], + }); + assert.equal(evaluateReleaseEvidence(accepted).productionReady, false); +}); + +test('release evidence rejects secrets and local paths', () => { + assert.throws( + () => createReleaseEvidence({ ...validEvidence(), residualRisks: ['token=secret-value'] }), + (error) => error.code === 'UNSAFE_RELEASE_EVIDENCE', + ); + assert.throws( + () => createReleaseEvidence({ + ...validEvidence(), + artifacts: [{ name: 'unsafe', path: 'C:\\Users\\Sythos\\Desktop\\evidence.json', purpose: 'not portable' }], + }), + (error) => error.code === 'UNSAFE_RELEASE_EVIDENCE', + ); +});