diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66ff5805..8b20741a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,41 @@ concurrency: cancel-in-progress: true jobs: + compose_runtime: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Start and probe Compose infrastructure + shell: bash + run: | + set -Eeuo pipefail + cleanup() { + status=$? + trap - EXIT + if [ "$status" -ne 0 ]; then + docker compose ps --all || true + docker compose logs --no-color --timestamps --tail 200 postgres nats || true + fi + docker compose down --volumes --remove-orphans || true + exit "$status" + } + trap cleanup EXIT + + docker compose up --detach --wait --wait-timeout 90 + docker compose exec --no-TTY postgres psql -U lifeos -d lifeos -v ON_ERROR_STOP=1 -tAc 'SELECT 1' | + grep -Fx 1 + curl --fail --silent --show-error --max-time 5 \ + http://127.0.0.1:8222/jsz | + jq -e '(.streams | type) == "number" and (.consumers | type) == "number"' \ + >/dev/null + validate: + needs: compose_runtime runs-on: ubuntu-latest timeout-minutes: 20 env: diff --git a/.github/workflows/commercial-readiness.yml b/.github/workflows/commercial-readiness.yml index 76af7468..d413a939 100644 --- a/.github/workflows/commercial-readiness.yml +++ b/.github/workflows/commercial-readiness.yml @@ -36,6 +36,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Set up Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -51,15 +52,19 @@ jobs: node packages/commercial-readiness/src/cli.mjs snapshot \ --repository "$GITHUB_REPOSITORY" \ --policy product/commercial-readiness-policy.json \ - --commit "$GITHUB_SHA" \ + --commit "${{ github.event.pull_request.head.sha || github.sha }}" \ --generated-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --output "$EVIDENCE_DIR/github-snapshot.json" - - name: Audit product capabilities and buyer gaps + - name: Audit capability maturity and canonical buyer gaps + env: + GITHUB_TOKEN: ${{ github.token }} run: | set -euo pipefail - node packages/commercial-readiness/src/cli.mjs audit \ + node packages/commercial-readiness/src/buyer-gap-cli.mjs \ + --repository "$GITHUB_REPOSITORY" \ --manifest product/capabilities.json \ + --buyer-gaps product/buyer-gaps.json \ --snapshot "$EVIDENCE_DIR/github-snapshot.json" \ --policy product/commercial-readiness-policy.json \ --root . \ diff --git a/.github/workflows/opencode-commercial-development.yml b/.github/workflows/opencode-commercial-development.yml index f6f563d4..3e85ef2d 100644 --- a/.github/workflows/opencode-commercial-development.yml +++ b/.github/workflows/opencode-commercial-development.yml @@ -242,20 +242,44 @@ jobs: COREPACK_HOME="$trusted_corepack_home" \ corepack install --global "$package_manager" chmod -R u=rwX,go=rX "$trusted_corepack_home" - MODEL_HOME="$model_home" NIM_BRIDGE_PORT="$NIM_BRIDGE_PORT" python3 - <<'PYCONFIG' + MODEL_HOME="$model_home" MODEL_WORKSPACE="$model_workspace" NIM_BRIDGE_PORT="$NIM_BRIDGE_PORT" RECEIPT_DIR="$RECEIPT_DIR" python3 - <<'PYCONFIG' import json import os from pathlib import Path + run = json.loads( + (Path(os.environ['RECEIPT_DIR']) / 'run.json').read_text(encoding='utf-8') + ) + model_label = run['model_label'] + provider_id, separator, model_id = model_label.partition('/') + if provider_id != 'nvidia' or separator != '/' or not model_id: + raise SystemExit('invalid_nvidia_model_label') + model_workspace_path = Path(os.environ['MODEL_WORKSPACE']) + instruction_files = [ + model_workspace_path / 'AGENTS.md', + model_workspace_path / 'CLAUDE.md', + ] + if any(not path.is_file() for path in instruction_files): + raise SystemExit('missing_reviewed_model_instruction') + instruction_paths = [str(path) for path in instruction_files] + config = { '$schema': 'https://opencode.ai/config.json', 'autoupdate': False, 'share': 'disabled', + 'enabled_providers': ['nvidia'], + 'model': model_label, + 'small_model': model_label, + 'instructions': instruction_paths, 'provider': { 'nvidia': { + 'whitelist': [model_id], + 'models': {model_id: {'name': model_id}}, 'options': { 'baseURL': f"http://127.0.0.1:{os.environ['NIM_BRIDGE_PORT']}/v1", 'apiKey': 'local-loopback-placeholder', + 'timeout': 60_000, + 'chunkTimeout': 30_000, }, }, }, @@ -305,9 +329,28 @@ jobs: echo 'MODEL_NETWORK_PHASE=model' } >> "$GITHUB_ENV" + - name: Validate the explicit OpenCode model catalog + id: model_catalog + if: steps.branch.outcome == 'success' + run: | + set -Eeuo pipefail + OPENCODE_MODEL="$(jq -r '.model_label' "$RECEIPT_DIR/run.json")" + sudo -u opencode_model env -i \ + HOME="$MODEL_HOME" \ + PATH="$PATH" \ + COREPACK_HOME="$TRUSTED_COREPACK_HOME" \ + COREPACK_ENABLE_NETWORK=0 \ + OPENCODE_CONFIG="$MODEL_HOME/opencode.json" \ + OPENCODE_DISABLE_AUTOUPDATE=true \ + OPENCODE_DISABLE_MODELS_FETCH=true \ + OPENCODE_DISABLE_PROJECT_CONFIG=true \ + NVIDIA_API_KEY=local-loopback-placeholder \ + bash -c 'cd "$1" && catalog="$(pnpm --filter @life-os/commercial-development-agent exec opencode models nvidia)" && test "$catalog" = "$2"' \ + _ "$MODEL_WORKSPACE" "$OPENCODE_MODEL" + - name: Start loopback NVIDIA credential bridge id: bridge - if: steps.branch.outcome == 'success' + if: steps.model_catalog.outcome == 'success' env: NVIDIA_NIM_API_KEY: ${{ secrets.NVIDIA_NIM_API_KEY }} run: | @@ -451,6 +494,8 @@ jobs: COREPACK_ENABLE_NETWORK=0 \ OPENCODE_CONFIG="$MODEL_HOME/opencode.json" \ OPENCODE_DISABLE_AUTOUPDATE=true \ + OPENCODE_DISABLE_MODELS_FETCH=true \ + OPENCODE_DISABLE_PROJECT_CONFIG=true \ NVIDIA_API_KEY=local-loopback-placeholder \ bash -c 'cd "$1" && timeout --signal=TERM --kill-after=30s 90m pnpm --filter @life-os/commercial-development-agent exec opencode run --pure --auto --model "$2" --format json --file .opencode-task.md "Execute the attached policy-isolated LifeOS task."' \ _ "$MODEL_WORKSPACE" "$OPENCODE_MODEL" \ @@ -692,7 +737,8 @@ jobs: id: verification if: steps.diff.outputs.accepted == 'true' run: | - set +e + set -Eeuo pipefail + status=0 sudo -u opencode_model env -i \ HOME="$MODEL_HOME" \ PATH="$PATH" \ @@ -705,12 +751,10 @@ jobs: HABIT_DATABASE_URL="$HABIT_DATABASE_URL" \ NOTIFICATION_DATABASE_URL="$NOTIFICATION_DATABASE_URL" \ PRIVACY_DATABASE_URL="$PRIVACY_DATABASE_URL" \ - bash -c 'cd "$1" && pnpm format:check && pnpm lint && pnpm typecheck && pnpm test && pnpm build && docker compose config >/dev/null' \ + bash -c 'cd "$1" && pnpm format:check && pnpm lint && pnpm typecheck && pnpm test && pnpm build' \ _ "$MODEL_WORKSPACE" \ - > "$RECEIPT_DIR/verification.log" 2>&1 - status=$? + > "$RECEIPT_DIR/verification.log" 2>&1 || status=$? sudo pkill --signal TERM --euid opencode_model 2>/dev/null || true - set -e chmod 0600 "$RECEIPT_DIR/verification.log" if [ "$status" -eq 0 ]; then echo 'passed=true' >> "$GITHUB_OUTPUT" @@ -718,8 +762,26 @@ jobs: echo 'passed=false' >> "$GITHUB_OUTPUT" fi - - name: Materialize verified candidate through trusted boundary + - name: Validate Compose configuration through trusted boundary + id: compose_config if: steps.verification.outputs.passed == 'true' + run: | + set -Eeuo pipefail + status=0 + docker compose \ + --file "$MODEL_WORKSPACE/compose.yaml" \ + --project-directory "$MODEL_WORKSPACE" \ + config --quiet \ + > "$RECEIPT_DIR/compose-validation.log" 2>&1 || status=$? + chmod 0600 "$RECEIPT_DIR/compose-validation.log" + if [ "$status" -eq 0 ]; then + echo 'passed=true' >> "$GITHUB_OUTPUT" + else + echo 'passed=false' >> "$GITHUB_OUTPUT" + fi + + - name: Materialize verified candidate through trusted boundary + if: steps.compose_config.outputs.passed == 'true' run: | set -Eeuo pipefail python3 - <<'PYMATERIALIZE' @@ -801,7 +863,7 @@ jobs: - name: Recheck the exact main base before remote mutation id: base - if: steps.verification.outputs.passed == 'true' + if: steps.compose_config.outputs.passed == 'true' run: | set -Eeuo pipefail current="$(timeout 30s git ls-remote origin refs/heads/main | awk 'NR == 1 {print $1}')" @@ -876,10 +938,12 @@ jobs: env: OPEN_PULL_REQUESTS: ${{ steps.github_evidence.outputs.open_pull_requests }} SELECTED: ${{ steps.selection.outputs.selected }} + MODEL_CATALOG_OUTCOME: ${{ steps.model_catalog.outcome }} BRIDGE_REASON: ${{ steps.bridge.outputs.reason }} MODEL_REASON: ${{ steps.model.outputs.reason }} DIFF_ACCEPTED: ${{ steps.diff.outputs.accepted }} VERIFICATION_PASSED: ${{ steps.verification.outputs.passed }} + COMPOSE_CONFIG_PASSED: ${{ steps.compose_config.outputs.passed }} BASE_MATCHED: ${{ steps.base.outputs.matched }} PR_CREATED: ${{ steps.mutation.outputs.created }} BRANCH_NAME: ${{ steps.branch.outputs.branch_name }} @@ -916,15 +980,20 @@ jobs: open_prs = os.environ.get('OPEN_PULL_REQUESTS', '0') selected = os.environ.get('SELECTED', 'false') == 'true' + model_catalog_outcome = os.environ.get('MODEL_CATALOG_OUTCOME', '') bridge_reason = os.environ.get('BRIDGE_REASON', '') model_reason = os.environ.get('MODEL_REASON', '') diff_accepted = os.environ.get('DIFF_ACCEPTED', '') == 'true' - verification = os.environ.get('VERIFICATION_PASSED', '') == 'true' + source_verification = os.environ.get('VERIFICATION_PASSED', '') == 'true' + compose_configuration = os.environ.get('COMPOSE_CONFIG_PASSED', '') == 'true' + verification = source_verification and compose_configuration base_matched = os.environ.get('BASE_MATCHED', '') == 'true' pr_created = os.environ.get('PR_CREATED', '') == 'true' if open_prs != '0' or not selected: status, reason = 'unavailable', 'no_eligible_issue' + elif model_catalog_outcome != 'success': + status, reason = 'failed', 'invalid_configuration' elif bridge_reason == 'provider_credential_missing': status, reason = 'unavailable', 'provider_credential_missing' elif bridge_reason != 'completed' or model_reason != 'completed': @@ -943,10 +1012,12 @@ jobs: validations = [ {'name': 'open_pull_request_drain', 'status': 'passed' if open_prs == '0' else 'failed'}, {'name': 'issue_policy', 'status': 'passed' if selected else 'skipped'}, - {'name': 'credential_bridge', 'status': 'skipped' if not selected else 'passed' if bridge_reason == 'completed' else 'failed'}, - {'name': 'provider_run', 'status': 'skipped' if not selected or bridge_reason != 'completed' else 'passed' if model_reason == 'completed' else 'failed'}, - {'name': 'diff_policy', 'status': 'passed' if diff_accepted else 'skipped' if not selected else 'failed'}, - {'name': 'repository_verification', 'status': 'passed' if verification else 'skipped' if not diff_accepted else 'failed'}, + {'name': 'model_catalog', 'status': 'skipped' if not selected or open_prs != '0' else 'passed' if model_catalog_outcome == 'success' else 'failed'}, + {'name': 'credential_bridge', 'status': 'skipped' if not selected or open_prs != '0' or model_catalog_outcome != 'success' else 'passed' if bridge_reason == 'completed' else 'failed'}, + {'name': 'provider_run', 'status': 'skipped' if not selected or open_prs != '0' or model_catalog_outcome != 'success' or bridge_reason != 'completed' else 'passed' if model_reason == 'completed' else 'failed'}, + {'name': 'diff_policy', 'status': 'skipped' if not selected or open_prs != '0' or model_catalog_outcome != 'success' or bridge_reason != 'completed' or model_reason != 'completed' else 'passed' if diff_accepted else 'failed'}, + {'name': 'repository_verification', 'status': 'passed' if source_verification else 'skipped' if not diff_accepted else 'failed'}, + {'name': 'compose_configuration', 'status': 'passed' if compose_configuration else 'skipped' if not source_verification else 'failed'}, {'name': 'base_sha', 'status': 'passed' if base_matched else 'skipped' if not verification else 'failed'}, ] input_value = { @@ -1012,6 +1083,7 @@ jobs: "$RECEIPT_DIR/prompt.txt" \ "$RECEIPT_DIR/opencode.log" \ "$RECEIPT_DIR/verification.log" \ + "$RECEIPT_DIR/compose-validation.log" \ "$RECEIPT_DIR/nim-bridge.py" \ "$RECEIPT_DIR/issues.json" \ "$RECEIPT_DIR/pulls.json" \ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e4c9a054..1b09f7e4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -113,6 +113,8 @@ Pull requests follow one loop: inspect every review and check, fix root causes, Scheduled model-assisted automation uses `NVIDIA_NIM_API_KEY`; `COPILOT_GITHUB_TOKEN` is prohibited. Existing dedicated review-agent credentials are not repurposed. Deterministic audit and merge eligibility remain independently enforceable even when a model provider is unavailable. +The pinned OpenCode configuration disables project-local overrides, explicitly reloads reviewed repository instructions, enables only NVIDIA, registers and whitelists one model label independently of the bundled catalog, pins primary and small-model work to it, and checks that effective catalog offline before its credential bridge starts; the bridge exposes no provider-wide discovery route. Model-generated source verification runs without Docker authority. A later trusted operation parses the accepted candidate's explicitly selected Compose file, while credential-free pull-request CI starts digest-pinned images, proves PostgreSQL query execution and NATS JetStream availability, binds published ports to loopback, and tears down unconditionally. + ## 6. Documentation hierarchy 1. `AGENTS.md` — repository-wide agent and merge rules. diff --git a/CHANGELOG.md b/CHANGELOG.md index a6982da2..4120874c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ All notable changes to LifeOS are documented in this file. ### Fixed +- The OpenCode development loop now prevents project settings from overriding its pinned offline NVIDIA model, records catalog failures accurately, parses the accepted candidate's exact Compose file outside the model account, and requires digest-pinned PostgreSQL queries plus NATS JetStream probes in pull-request CI. - Live contextual-orchestrator responses now classify successful empty bodies as evaluation failures, emit exactly one terminal observation, canonicalize retained timestamps safely, and preserve null metric denominators instead of fabricating deltas. - Stale AI proposal revision conflicts now belong to the technology-independent audit domain while the PostgreSQL adapter preserves its compatibility export. - Planning search now normalizes browser query text and prevents stale or unmounted requests from replacing the latest visible result state. @@ -32,6 +33,7 @@ All notable changes to LifeOS are documented in this file. ### Security +- The commercial-development model account no longer performs Docker commands, never receives Docker-socket authority, and cannot trigger provider-wide model discovery through the credential bridge. - The scheduled live-model harness uses only `NVIDIA_NIM_API_KEY`, seeds it through the encrypted contextual-orchestrator credential registry, installs hash-locked dependencies from an exact commit, confines LifeOS traffic to loopback, allowlists NVIDIA NIM egress, and excludes provider credentials, prompts, responses, traces, and hidden reasoning from retained artifacts. - Proposal quality reports now discard nested model failures and response bodies, normalize labeled sentinel checks, expose no provider credential or mutation dependency, and measure prompt-injection resistance together with benign utility instead of rewarding blanket refusal. - External proposal generation now accepts only one credential-free HTTPS orchestrator origin, stops responses at 65536 bytes, enforces a bounded abort timeout, supplies no tools, treats planning context as untrusted data, and exposes only sanitized failures. diff --git a/apps/identity-service/migrations/0004_session_authentication_age.sql b/apps/identity-service/migrations/0004_session_authentication_age.sql new file mode 100644 index 00000000..2bde6833 --- /dev/null +++ b/apps/identity-service/migrations/0004_session_authentication_age.sql @@ -0,0 +1,35 @@ +ALTER TABLE identity.sessions + ADD COLUMN authenticated_at timestamptz; + +WITH RECURSIVE session_authentication_lineage AS ( + SELECT + session_row.id, + session_row.user_id, + session_row.workspace_id, + session_row.created_at AS root_authenticated_at + FROM identity.sessions AS session_row + WHERE session_row.rotated_from_id IS NULL + + UNION ALL + + SELECT + child_session.id, + child_session.user_id, + child_session.workspace_id, + parent_session.root_authenticated_at + FROM identity.sessions AS child_session + JOIN session_authentication_lineage AS parent_session + ON child_session.rotated_from_id = parent_session.id + AND child_session.user_id = parent_session.user_id + AND child_session.workspace_id = parent_session.workspace_id +) +UPDATE identity.sessions AS session_row +SET authenticated_at = lineage.root_authenticated_at +FROM session_authentication_lineage AS lineage +WHERE session_row.id = lineage.id; + +ALTER TABLE identity.sessions + ADD CONSTRAINT sessions_authentication_present + CHECK (authenticated_at IS NOT NULL) NOT VALID, + ADD CONSTRAINT sessions_authentication_not_after_creation + CHECK (authenticated_at <= created_at) NOT VALID; diff --git a/apps/identity-service/migrations/0005_finalize_session_authentication_age.sql b/apps/identity-service/migrations/0005_finalize_session_authentication_age.sql new file mode 100644 index 00000000..e12f3b68 --- /dev/null +++ b/apps/identity-service/migrations/0005_finalize_session_authentication_age.sql @@ -0,0 +1,11 @@ +ALTER TABLE identity.sessions + VALIDATE CONSTRAINT sessions_authentication_present; + +ALTER TABLE identity.sessions + VALIDATE CONSTRAINT sessions_authentication_not_after_creation; + +ALTER TABLE identity.sessions + ALTER COLUMN authenticated_at SET NOT NULL; + +ALTER TABLE identity.sessions + DROP CONSTRAINT sessions_authentication_present; diff --git a/apps/identity-service/src/auth-security.ts b/apps/identity-service/src/auth-security.ts index 723a90cd..5a23785c 100644 --- a/apps/identity-service/src/auth-security.ts +++ b/apps/identity-service/src/auth-security.ts @@ -210,6 +210,7 @@ export interface SessionRecord { userId: string; workspaceId: string; tokenHash: string; + authenticatedAt: string; createdAt: string; expiresAt: string; revokedAt: string | null; @@ -220,6 +221,7 @@ export interface ActiveSession { id: string; userId: string; workspaceId: string; + authenticatedAt: string; createdAt: string; expiresAt: string; rotatedFromId?: string; @@ -259,6 +261,7 @@ function toActiveSession(session: SessionRecord): ActiveSession { id: session.id, userId: session.userId, workspaceId: session.workspaceId, + authenticatedAt: session.authenticatedAt, createdAt: session.createdAt, expiresAt: session.expiresAt, ...(session.rotatedFromId ? { rotatedFromId: session.rotatedFromId } : {}), @@ -296,7 +299,12 @@ export class SessionService { if (!(await this.repository.revokeByTokenHash(current.tokenHash, this.now().toISOString()))) { throw new Error(INVALID_SESSION); } - return this.issue(current.userId, current.workspaceId, current.id); + return this.issue( + current.userId, + current.workspaceId, + current.id, + current.authenticatedAt, + ); } async revoke(token: string): Promise { @@ -310,6 +318,7 @@ export class SessionService { userId: string, workspaceId: string, rotatedFromId: string | null, + authenticatedAt?: string, ): Promise<{ session: ActiveSession; token: string }> { if (!UUID_V4_PATTERN.test(userId)) { throw new Error('User ID must be an opaque UUIDv4'); @@ -325,6 +334,7 @@ export class SessionService { userId, workspaceId, tokenHash: sha256Hex(token), + authenticatedAt: authenticatedAt ?? now.toISOString(), createdAt: now.toISOString(), expiresAt: new Date(now.getTime() + this.ttlMs).toISOString(), revokedAt: null, diff --git a/apps/identity-service/src/authentication-age.test.ts b/apps/identity-service/src/authentication-age.test.ts new file mode 100644 index 00000000..ec3e7628 --- /dev/null +++ b/apps/identity-service/src/authentication-age.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { InMemorySessionRepository, SessionService } from './auth-security'; +import { toSessionView } from './oauth-http-boundary'; + +const USER_ID = 'a89f36b4-1f3c-4e62-a4e1-7ba3eb3b8ac1'; +const WORKSPACE_ID = 'b89f36b4-1f3c-4e62-a4e1-7ba3eb3b8ac2'; + +describe('session authentication age provenance', () => { + it('records the authentication instant separately from session issuance', async () => { + const authenticatedAt = new Date('2026-08-09T12:00:00.000Z'); + const service = new SessionService(new InMemorySessionRepository(), { + now: () => authenticatedAt, + }); + + const issued = await service.create(USER_ID, WORKSPACE_ID); + + expect(issued.session.authenticatedAt).toBe(authenticatedAt.toISOString()); + expect(issued.session.createdAt).toBe(authenticatedAt.toISOString()); + expect(toSessionView(issued.session)).toMatchObject({ + authenticatedAt: authenticatedAt.toISOString(), + }); + }); + + it('preserves the original authentication instant across session rotation', async () => { + let now = new Date('2026-08-09T12:00:00.000Z'); + const service = new SessionService(new InMemorySessionRepository(), { + now: () => now, + }); + const issued = await service.create(USER_ID, WORKSPACE_ID); + + now = new Date('2026-08-09T12:30:00.000Z'); + const rotated = await service.rotate(issued.token); + + expect(rotated.session.createdAt).toBe(now.toISOString()); + expect(rotated.session.authenticatedAt).toBe( + issued.session.authenticatedAt, + ); + expect(rotated.session.authenticatedAt).not.toBe(rotated.session.createdAt); + }); +}); diff --git a/apps/identity-service/src/oauth-callback-application.ts b/apps/identity-service/src/oauth-callback-application.ts index 504de8fc..755aaabd 100644 --- a/apps/identity-service/src/oauth-callback-application.ts +++ b/apps/identity-service/src/oauth-callback-application.ts @@ -1,4 +1,4 @@ -import type { ActiveSession, ConsumedOAuthTransaction } from './auth-security'; +import type { ConsumedOAuthTransaction } from './auth-security'; import type { IdentityProvider, MaybePromise, @@ -62,12 +62,17 @@ export interface ExternalIdentityProvisioner { }): Promise; } +/** Minimum session metadata needed to issue one browser cookie. */ +export interface WorkspaceIssuedSession { + expiresAt: string; +} + /** Issues and revokes opaque workspace-scoped application sessions. */ export interface WorkspaceSessionIssuer { create( userId: string, workspaceId: string, - ): Promise<{ session: ActiveSession; token: string }>; + ): Promise<{ session: WorkspaceIssuedSession; token: string }>; revoke(token: string): Promise; } diff --git a/apps/identity-service/src/oauth-http-boundary.ts b/apps/identity-service/src/oauth-http-boundary.ts index 0bc9109c..cef4de2c 100644 --- a/apps/identity-service/src/oauth-http-boundary.ts +++ b/apps/identity-service/src/oauth-http-boundary.ts @@ -310,6 +310,7 @@ export function toSessionView(session: ActiveSession): { sessionId: string; userId: string; workspaceId: string; + authenticatedAt: string; createdAt: string; expiresAt: string; } { @@ -317,6 +318,7 @@ export function toSessionView(session: ActiveSession): { sessionId: session.id, userId: session.userId, workspaceId: session.workspaceId, + authenticatedAt: session.authenticatedAt, createdAt: session.createdAt, expiresAt: session.expiresAt, }; diff --git a/apps/identity-service/src/postgres-security-repositories.integration.test.ts b/apps/identity-service/src/postgres-security-repositories.integration.test.ts index 73748f1c..fc824970 100644 --- a/apps/identity-service/src/postgres-security-repositories.integration.test.ts +++ b/apps/identity-service/src/postgres-security-repositories.integration.test.ts @@ -219,7 +219,7 @@ describeWithDatabase('PostgreSQL identity security repositories', () => { ); }); - it('enforces workspace ownership and rotates persisted sessions atomically at revocation', async () => { + it('enforces workspace ownership and preserves authentication age across persisted rotation', async () => { const userId = randomUUID(); const otherUserId = randomUUID(); const workspaceId = randomUUID(); @@ -235,8 +235,9 @@ describeWithDatabase('PostgreSQL identity security repositories', () => { ); const repository = new PostgresSessionRepository(sqlClient); + let now = new Date('2026-08-03T01:30:00.000Z'); const service = new SessionService(repository, { - now: () => new Date('2026-08-03T01:30:00.000Z'), + now: () => now, ttlMs: 60 * 60 * 1000, }); const issued = await service.create(userId, workspaceId); @@ -244,9 +245,11 @@ describeWithDatabase('PostgreSQL identity security repositories', () => { const stored = await pool.query<{ token_hash: string; workspace_id: string; + authenticated_at: Date; + created_at: Date; revoked_at: Date | null; }>( - `SELECT token_hash, workspace_id, revoked_at + `SELECT token_hash, workspace_id, authenticated_at, created_at, revoked_at FROM identity.sessions WHERE id = $1`, [issued.session.id], @@ -254,11 +257,14 @@ describeWithDatabase('PostgreSQL identity security repositories', () => { expect(stored.rows[0]).toMatchObject({ token_hash: sha256Hex(issued.token), workspace_id: workspaceId, + authenticated_at: new Date(issued.session.authenticatedAt), + created_at: new Date(issued.session.createdAt), revoked_at: null, }); expect(stored.rows[0]?.token_hash).not.toBe(issued.token); await expect(service.authenticate(issued.token)).resolves.toEqual(issued.session); + now = new Date('2026-08-03T01:45:00.000Z'); const rotated = await service.rotate(issued.token); await expect(service.authenticate(issued.token)).rejects.toThrowError( 'Session is invalid or expired', @@ -269,18 +275,33 @@ describeWithDatabase('PostgreSQL identity security repositories', () => { 'SELECT revoked_at FROM identity.sessions WHERE id = $1', [issued.session.id], ); - const replacement = await pool.query<{ rotated_from_id: string | null }>( - 'SELECT rotated_from_id FROM identity.sessions WHERE id = $1', + const replacement = await pool.query<{ + rotated_from_id: string | null; + authenticated_at: Date; + created_at: Date; + }>( + `SELECT rotated_from_id, authenticated_at, created_at + FROM identity.sessions + WHERE id = $1`, [rotated.session.id], ); expect(oldSession.rows[0]?.revoked_at).toBeInstanceOf(Date); expect(replacement.rows[0]?.rotated_from_id).toBe(issued.session.id); + expect(replacement.rows[0]?.authenticated_at.toISOString()).toBe( + issued.session.authenticatedAt, + ); + expect(replacement.rows[0]?.created_at.toISOString()).toBe( + rotated.session.createdAt, + ); + expect(rotated.session.authenticatedAt).toBe(issued.session.authenticatedAt); + expect(rotated.session.createdAt).not.toBe(issued.session.createdAt); const crossTenantSession: SessionRecord = { id: randomUUID(), userId: otherUserId, workspaceId, tokenHash: 'a'.repeat(64), + authenticatedAt: '2026-08-03T01:30:00.000Z', createdAt: '2026-08-03T01:30:00.000Z', expiresAt: '2026-08-03T02:30:00.000Z', revokedAt: null, diff --git a/apps/identity-service/src/postgres-security-repositories.test.ts b/apps/identity-service/src/postgres-security-repositories.test.ts index 01322a56..9e1de5db 100644 --- a/apps/identity-service/src/postgres-security-repositories.test.ts +++ b/apps/identity-service/src/postgres-security-repositories.test.ts @@ -68,6 +68,7 @@ function sessionFixture(): SessionRecord { userId: 'c89f36b4-1f3c-4e62-a4e1-7ba3eb3b8ac3', workspaceId: 'd89f36b4-1f3c-4e62-a4e1-7ba3eb3b8ac4', tokenHash: 'c'.repeat(64), + authenticatedAt: '2026-08-03T00:00:00.000Z', createdAt: '2026-08-03T00:00:00.000Z', expiresAt: '2026-09-02T00:00:00.000Z', revokedAt: null, @@ -176,7 +177,7 @@ describe('PostgresOAuthTransactionRepository', () => { }); describe('PostgresSessionRepository', () => { - it('persists and maps workspace-scoped sessions with parameterized SQL', async () => { + it('persists and maps workspace-scoped sessions with authentication provenance', async () => { const client = new RecordingSqlClient(); const repository = new PostgresSessionRepository(client); const session = sessionFixture(); @@ -184,8 +185,10 @@ describe('PostgresSessionRepository', () => { await repository.save(session); const saveCall = requireCall(client); expect(saveCall.text).toContain('INSERT INTO identity.sessions'); + expect(saveCall.text).toContain('authenticated_at'); expect(saveCall.text).not.toContain(session.tokenHash); expect(saveCall.values).toContain(session.workspaceId); + expect(saveCall.values).toContain(session.authenticatedAt); client.enqueue({ rowCount: 1, @@ -195,6 +198,7 @@ describe('PostgresSessionRepository', () => { user_id: session.userId, workspace_id: session.workspaceId, token_hash: session.tokenHash, + authenticated_at: session.authenticatedAt, created_at: session.createdAt, expires_at: session.expiresAt, revoked_at: null, diff --git a/apps/identity-service/src/postgres-security-repositories.ts b/apps/identity-service/src/postgres-security-repositories.ts index 19e5db7c..7873d637 100644 --- a/apps/identity-service/src/postgres-security-repositories.ts +++ b/apps/identity-service/src/postgres-security-repositories.ts @@ -36,6 +36,7 @@ interface SessionRow { user_id: unknown; workspace_id: unknown; token_hash: unknown; + authenticated_at: unknown; created_at: unknown; expires_at: unknown; revoked_at: unknown; @@ -238,6 +239,7 @@ function mapSessionRow(row: SessionRow): SessionRecord { userId: requireString(row.user_id, 'Stored session is invalid'), workspaceId: requireString(row.workspace_id, 'Stored session is invalid'), tokenHash: requireString(row.token_hash, 'Stored session is invalid'), + authenticatedAt: toIsoString(row.authenticated_at, 'Stored session is invalid'), createdAt: toIsoString(row.created_at, 'Stored session is invalid'), expiresAt: toIsoString(row.expires_at, 'Stored session is invalid'), revokedAt: optionalIsoString(row.revoked_at, 'Stored session is invalid'), @@ -258,16 +260,18 @@ export class PostgresSessionRepository implements SessionRepository { user_id, workspace_id, token_hash, + authenticated_at, created_at, expires_at, revoked_at, rotated_from_id - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [ session.id, session.userId, session.workspaceId, session.tokenHash, + session.authenticatedAt, session.createdAt, session.expiresAt, session.revokedAt, @@ -283,6 +287,7 @@ export class PostgresSessionRepository implements SessionRepository { user_id, workspace_id, token_hash, + authenticated_at, created_at, expires_at, revoked_at, diff --git a/apps/identity-service/tests/session-authentication-migration-contract.test.ts b/apps/identity-service/tests/session-authentication-migration-contract.test.ts new file mode 100644 index 00000000..73d04233 --- /dev/null +++ b/apps/identity-service/tests/session-authentication-migration-contract.test.ts @@ -0,0 +1,62 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const AUTHENTICATION_MIGRATION = '0004_session_authentication_age.sql'; +const AUTHENTICATION_FINALIZATION_MIGRATION = + '0005_finalize_session_authentication_age.sql'; +const STAGED_VALIDATION_CLAUSE = ['NOT', 'VALID'].join(' '); +const VALIDATE_CONSTRAINT_CLAUSE = ['VALIDATE', 'CONSTRAINT'].join(' '); +const SET_NOT_NULL_CLAUSE = ['SET', 'NOT', 'NULL'].join(' '); + +/** Reads one identity migration from the package-owned migration directory. */ +async function readMigration(fileName: string): Promise { + return readFile(resolve(process.cwd(), 'migrations', fileName), 'utf8'); +} + +/** Collapses SQL layout whitespace without changing the asserted SQL tokens. */ +function normalizeSql(source: string): string { + return source.replace(/\s+/gu, ' ').trim(); +} + +describe('session authentication-age migration contract', () => { + it('stages authentication constraints before the full validation migration', async () => { + const migration = normalizeSql( + await readMigration(AUTHENTICATION_MIGRATION), + ); + + expect(migration).toContain( + `ADD CONSTRAINT sessions_authentication_present CHECK (authenticated_at IS NOT NULL) ${STAGED_VALIDATION_CLAUSE}`, + ); + expect(migration).toContain( + `ADD CONSTRAINT sessions_authentication_not_after_creation CHECK (authenticated_at <= created_at) ${STAGED_VALIDATION_CLAUSE}`, + ); + expect( + migration.includes(`ALTER COLUMN authenticated_at ${SET_NOT_NULL_CLAUSE}`), + ).toBe(false); + expect(migration.includes(VALIDATE_CONSTRAINT_CLAUSE)).toBe(false); + }); + + it('validates both constraints before the short final not-null transition', async () => { + const migration = normalizeSql( + await readMigration(AUTHENTICATION_FINALIZATION_MIGRATION), + ); + const presenceValidation = migration.indexOf( + `${VALIDATE_CONSTRAINT_CLAUSE} sessions_authentication_present`, + ); + const chronologyValidation = migration.indexOf( + `${VALIDATE_CONSTRAINT_CLAUSE} sessions_authentication_not_after_creation`, + ); + const notNullTransition = migration.indexOf( + `ALTER COLUMN authenticated_at ${SET_NOT_NULL_CLAUSE}`, + ); + + expect(presenceValidation).toBeGreaterThanOrEqual(0); + expect(chronologyValidation).toBeGreaterThanOrEqual(0); + expect(notNullTransition).toBeGreaterThan(presenceValidation); + expect(notNullTransition).toBeGreaterThan(chronologyValidation); + expect(migration).toContain( + 'DROP CONSTRAINT sessions_authentication_present', + ); + }); +}); diff --git a/apps/identity-service/tests/session-authentication-migration.integration.test.ts b/apps/identity-service/tests/session-authentication-migration.integration.test.ts new file mode 100644 index 00000000..3183112a --- /dev/null +++ b/apps/identity-service/tests/session-authentication-migration.integration.test.ts @@ -0,0 +1,331 @@ +import { randomUUID } from 'node:crypto'; +import { readdir, readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { Pool } from 'pg'; +import { describe, expect, it } from 'vitest'; + +const DATABASE_URL = process.env.IDENTITY_DATABASE_URL; +const TEMPORARY_DATABASE_NAME = 'life_os_identity_migration_test'; +const TEMPORARY_DATABASE_LOCK_KEY = 74_211_340; +const describeWithDatabase = DATABASE_URL ? describe : describe.skip; +const AUTHENTICATION_MIGRATION = '0004_session_authentication_age.sql'; +const AUTHENTICATION_FINALIZATION_MIGRATION = + '0005_finalize_session_authentication_age.sql'; + +function requireDatabaseUrl(): string { + if (!DATABASE_URL) { + throw new Error('IDENTITY_DATABASE_URL is required for PostgreSQL integration tests'); + } + return DATABASE_URL; +} + +function databaseUrl(sourceUrl: string, name: string): string { + const parsed = new URL(sourceUrl); + parsed.pathname = `/${name}`; + return parsed.toString(); +} + +async function migrationFilesBeforeAuthenticationAge(): Promise { + const migrationDirectory = resolve(process.cwd(), 'migrations'); + return (await readdir(migrationDirectory)) + .filter( + (file) => + file.endsWith('.sql') && file.localeCompare(AUTHENTICATION_MIGRATION) < 0, + ) + .sort(); +} + +async function readMigration(fileName: string): Promise { + return readFile(resolve(process.cwd(), 'migrations', fileName), 'utf8'); +} + +async function prepareLegacyDatabase(pool: Pool): Promise { + const migrationDirectory = resolve(process.cwd(), 'migrations'); + for (const migrationFile of await migrationFilesBeforeAuthenticationAge()) { + const sql = await readFile(resolve(migrationDirectory, migrationFile), 'utf8'); + await pool.query(sql); + } +} + +async function applyAuthenticationAgeMigrations(pool: Pool): Promise { + await pool.query(await readMigration(AUTHENTICATION_MIGRATION)); + await pool.query(await readMigration(AUTHENTICATION_FINALIZATION_MIGRATION)); +} + +async function withTemporaryDatabase( + execute: (pool: Pool) => Promise, +): Promise { + const sourceUrl = requireDatabaseUrl(); + const adminPool = new Pool({ + connectionString: databaseUrl(sourceUrl, 'postgres'), + }); + let migrationPool: Pool | undefined; + let lockHeld = false; + + try { + await adminPool.query('SELECT pg_advisory_lock($1::bigint)', [ + TEMPORARY_DATABASE_LOCK_KEY, + ]); + lockHeld = true; + await adminPool.query( + 'DROP DATABASE IF EXISTS life_os_identity_migration_test WITH (FORCE)', + ); + await adminPool.query('CREATE DATABASE life_os_identity_migration_test'); + migrationPool = new Pool({ + connectionString: databaseUrl(sourceUrl, TEMPORARY_DATABASE_NAME), + }); + await prepareLegacyDatabase(migrationPool); + await execute(migrationPool); + } finally { + try { + await migrationPool?.end(); + } finally { + try { + if (lockHeld) { + try { + await adminPool.query( + 'DROP DATABASE IF EXISTS life_os_identity_migration_test WITH (FORCE)', + ); + } finally { + await adminPool.query('SELECT pg_advisory_unlock($1::bigint)', [ + TEMPORARY_DATABASE_LOCK_KEY, + ]); + } + } + } finally { + await adminPool.end(); + } + } + } +} + +async function insertUserAndWorkspace( + pool: Pool, + userId: string, + workspaceId: string, + suffix: string, +): Promise { + await pool.query( + `INSERT INTO identity.users (id, display_name) + VALUES ($1, $2)`, + [userId, `Legacy migration user ${suffix}`], + ); + await pool.query( + `INSERT INTO identity.workspaces (id, owner_user_id, name, kind) + VALUES ($1, $2, $3, 'personal')`, + [workspaceId, userId, `Legacy migration workspace ${suffix}`], + ); +} + +async function insertSession( + pool: Pool, + { + id, + userId, + workspaceId, + tokenSeed, + createdAt, + expiresAt, + revokedAt = null, + rotatedFromId = null, + }: Readonly<{ + id: string; + userId: string; + workspaceId: string; + tokenSeed: string; + createdAt: string; + expiresAt: string; + revokedAt?: string | null; + rotatedFromId?: string | null; + }>, +): Promise { + await pool.query( + `INSERT INTO identity.sessions ( + id, user_id, workspace_id, token_hash, created_at, expires_at, + revoked_at, rotated_from_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + id, + userId, + workspaceId, + tokenSeed.repeat(64), + createdAt, + expiresAt, + revokedAt, + rotatedFromId, + ], + ); +} + +describeWithDatabase('session authentication-age migration', () => { + it('serializes concurrent migration fixtures that share the disposable database', async () => { + const completed: string[] = []; + + await Promise.all([ + withTemporaryDatabase(async (migrationPool) => { + await migrationPool.query('SELECT pg_sleep(0.05)'); + completed.push('first'); + }), + withTemporaryDatabase(async (migrationPool) => { + await migrationPool.query('SELECT 1'); + completed.push('second'); + }), + ]); + + expect(completed).toHaveLength(2); + expect(new Set(completed)).toEqual(new Set(['first', 'second'])); + }, 30_000); + + it('backfills every legacy rotated session from its authenticated chain root', async () => { + await withTemporaryDatabase(async (migrationPool) => { + const userId = randomUUID(); + const workspaceId = randomUUID(); + const rootSessionId = randomUUID(); + const childSessionId = randomUUID(); + const grandchildSessionId = randomUUID(); + const rootAuthenticatedAt = '2026-08-03T01:00:00.000Z'; + const childCreatedAt = '2026-08-03T01:15:00.000Z'; + const grandchildCreatedAt = '2026-08-03T01:30:00.000Z'; + + await insertUserAndWorkspace( + migrationPool, + userId, + workspaceId, + 'valid', + ); + await insertSession(migrationPool, { + id: rootSessionId, + userId, + workspaceId, + tokenSeed: '1', + createdAt: rootAuthenticatedAt, + expiresAt: '2026-08-03T02:00:00.000Z', + revokedAt: childCreatedAt, + }); + await insertSession(migrationPool, { + id: childSessionId, + userId, + workspaceId, + tokenSeed: '2', + createdAt: childCreatedAt, + expiresAt: '2026-08-03T02:15:00.000Z', + revokedAt: grandchildCreatedAt, + rotatedFromId: rootSessionId, + }); + await insertSession(migrationPool, { + id: grandchildSessionId, + userId, + workspaceId, + tokenSeed: '3', + createdAt: grandchildCreatedAt, + expiresAt: '2026-08-03T02:30:00.000Z', + rotatedFromId: childSessionId, + }); + + await applyAuthenticationAgeMigrations(migrationPool); + + const migrated = await migrationPool.query<{ + id: string; + authenticated_at: Date; + created_at: Date; + }>( + `SELECT id, authenticated_at, created_at + FROM identity.sessions + ORDER BY created_at ASC`, + ); + + expect(migrated.rows).toHaveLength(3); + expect( + migrated.rows.map((row) => row.authenticated_at.toISOString()), + ).toEqual([ + rootAuthenticatedAt, + rootAuthenticatedAt, + rootAuthenticatedAt, + ]); + expect(migrated.rows[1]?.created_at.toISOString()).toBe(childCreatedAt); + expect(migrated.rows[2]?.created_at.toISOString()).toBe( + grandchildCreatedAt, + ); + }); + }, 30_000); + + it('rejects a rotation lineage that crosses valid user-workspace ownership pairs', async () => { + await withTemporaryDatabase(async (migrationPool) => { + const rootUserId = randomUUID(); + const childUserId = randomUUID(); + const rootWorkspaceId = randomUUID(); + const childWorkspaceId = randomUUID(); + const rootSessionId = randomUUID(); + + await insertUserAndWorkspace( + migrationPool, + rootUserId, + rootWorkspaceId, + 'root-owner', + ); + await insertUserAndWorkspace( + migrationPool, + childUserId, + childWorkspaceId, + 'child-owner', + ); + await insertSession(migrationPool, { + id: rootSessionId, + userId: rootUserId, + workspaceId: rootWorkspaceId, + tokenSeed: '4', + createdAt: '2026-08-03T01:00:00.000Z', + expiresAt: '2026-08-03T02:00:00.000Z', + }); + await insertSession(migrationPool, { + id: randomUUID(), + userId: childUserId, + workspaceId: childWorkspaceId, + tokenSeed: '5', + createdAt: '2026-08-03T01:15:00.000Z', + expiresAt: '2026-08-03T02:15:00.000Z', + rotatedFromId: rootSessionId, + }); + + await migrationPool.query(await readMigration(AUTHENTICATION_MIGRATION)); + await expect( + migrationPool.query( + await readMigration(AUTHENTICATION_FINALIZATION_MIGRATION), + ), + ).rejects.toThrow(/sessions_authentication_present/u); + }); + }, 30_000); + + it('keeps cross-workspace user mismatches impossible before authentication-age migration', async () => { + await withTemporaryDatabase(async (migrationPool) => { + const rootUserId = randomUUID(); + const otherUserId = randomUUID(); + const rootWorkspaceId = randomUUID(); + const otherWorkspaceId = randomUUID(); + + await insertUserAndWorkspace( + migrationPool, + rootUserId, + rootWorkspaceId, + 'root-workspace', + ); + await insertUserAndWorkspace( + migrationPool, + otherUserId, + otherWorkspaceId, + 'other-workspace', + ); + + await expect( + insertSession(migrationPool, { + id: randomUUID(), + userId: rootUserId, + workspaceId: otherWorkspaceId, + tokenSeed: '6', + createdAt: '2026-08-03T01:15:00.000Z', + expiresAt: '2026-08-03T02:15:00.000Z', + }), + ).rejects.toThrow(/sessions_workspace_owner_fk/u); + }); + }, 30_000); +}); diff --git a/compose.yaml b/compose.yaml index 9f3222b8..7c44ce4f 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,12 +1,12 @@ services: postgres: - image: postgres:17-alpine + image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193 environment: POSTGRES_USER: lifeos POSTGRES_PASSWORD: lifeos POSTGRES_DB: lifeos ports: - - '5432:5432' + - '127.0.0.1:5432:5432' volumes: - lifeos-postgres:/var/lib/postgresql/data healthcheck: @@ -16,11 +16,11 @@ services: retries: 10 nats: - image: nats:2.11-alpine + image: nats:2.11.6-alpine@sha256:e4bf19f15fd3218814a4e3c9e0064e1334bd8aa20d5984b9f1a0afd084f8cc00 command: ['-js', '-m', '8222'] ports: - - '4222:4222' - - '8222:8222' + - '127.0.0.1:4222:4222' + - '127.0.0.1:8222:8222' volumes: - lifeos-nats:/data healthcheck: diff --git a/docs/operations/opencode-commercial-development-loop.md b/docs/operations/opencode-commercial-development-loop.md index 67c41b65..213caaac 100644 --- a/docs/operations/opencode-commercial-development-loop.md +++ b/docs/operations/opencode-commercial-development-loop.md @@ -7,7 +7,7 @@ The `OpenCode Commercial Development` workflow may implement one explicitly elig ## Enablement prerequisites 1. Store the NVIDIA provider credential as the repository secret `NVIDIA_NIM_API_KEY`. -2. Optionally configure repository variable `OPENCODE_NVIDIA_MODEL` with one NVIDIA NIM chat model identifier. When the value omits the provider prefix, the workflow prefixes `nvidia/`. +2. Optionally configure repository variable `OPENCODE_NVIDIA_MODEL` with one reviewed NVIDIA NIM chat model identifier. When the value omits the provider prefix, the workflow prefixes `nvidia/`. The private configuration registers that identifier in `provider.nvidia.models`, whitelists only it, and requires the offline preflight to return exactly that label before the credential bridge starts. 3. Keep `product/opencode-commercial-development-policy.json` under normal pull-request review. 4. Add an issue title to `eligible_issue_titles` only through a reviewed pull request after confirming the issue fits the initial non-destructive write boundary. 5. Verify the exact OpenCode package and `pnpm-lock.yaml` pin after every OpenCode update. @@ -52,6 +52,8 @@ sequenceDiagram During model execution, UID-based `iptables` rules reject other IPv4 and all IPv6 egress from `opencode_model`, permitting only the configured loopback bridge port. The workflow terminates the bridge before repository verification and removes the model and bridge processes, firewall rules, private homes, configuration, prompt, bridge code, and log in the `always()` cleanup step. +OpenCode catalog refresh and project-local configuration discovery are disabled. The private configuration explicitly reloads the reviewed workspace `AGENTS.md` and `CLAUDE.md`, enables only NVIDIA, registers and whitelists exactly the selected model under `provider.nvidia.models`, pins both primary and small-model work to that label, and validates the exact label before the bridge starts. This registration also covers a reviewed NVIDIA model that is absent from OpenCode's bundled snapshot. A failed catalog preflight is retained as `invalid_configuration`; later bridge and provider validations remain skipped. The bridge therefore exposes only `/healthz` and bounded `/v1/chat/completions`; it does not proxy provider-wide model discovery. + The credential must never appear in: - Git configuration; @@ -71,6 +73,10 @@ A suspected credential disclosure requires immediate secret rotation, cancellati The checkout disables persisted credentials. OpenCode receives no `GITHUB_TOKEN` or `GH_TOKEN`. A later deterministic step receives `github.token` only after the diff, repository tests, and exact base SHA pass. That step may create one commit, push one same-repository UUIDv4 branch, and open one draft pull request. It cannot merge or release. +### Docker authority and runtime proof + +The isolated `opencode_model` account runs format, lint, typecheck, test, and build commands but never receives Docker-group membership or Docker-socket access. A separate trusted step passes the accepted candidate's exact `compose.yaml` through `--file` and runs only `docker compose config --quiet`; `--project-directory` supplies its path-resolution base. The resulting draft pull request then enters normal credential-free CI, which starts digest-pinned PostgreSQL and NATS images with `docker compose up --wait`, executes PostgreSQL `SELECT 1`, validates the NATS JetStream `/jsz` response shape, prints only a bounded timestamped log tail on failure, and tears down containers and volumes on every exit. Compose publishes its development ports on loopback only. + ## Policy changes Changes to allowed paths, issue titles, limits, model profiles, credentials, permissions, or mutation authority are security-sensitive architecture changes. They require: @@ -104,11 +110,11 @@ Retention is seven days. The receipt records counts, stable classifications, exa | `prompt_rejected` | Prompt exceeds or violates the fixed contract | None | | `diff_rejected` | Working-tree output violates path, object, size, content, or no-change policy | None | | `base_changed` | `main` advanced after the run began | None | -| `verification_failed` | Repository tests or build failed | None | +| `verification_failed` | Repository tests, build, or trusted Compose parsing failed | None | | `draft_pull_request_failed` | A validated commit could not become a draft PR | Possible unreferenced automation branch; reconcile immediately | | `completed` | One draft PR was created; normal review is still required | One branch and one draft PR | -The receipt contract reserves `opencode_unavailable`, `invalid_configuration`, and `prompt_rejected`; the current workflow receipt composer does not emit those codes. +The receipt composer emits `invalid_configuration` when the explicit model-catalog preflight does not succeed. The contract reserves `opencode_unavailable` and `prompt_rejected` for future deterministic classifications that the current workflow does not emit. ## Branch reconciliation @@ -133,7 +139,7 @@ Never force-push an automation branch. If `main` advances, abandon the branch an 2. Resolve the current official `opencode-ai` version once. 3. Add it with an exact version and update `pnpm-lock.yaml`. 4. Verify `opencode --version` and `opencode run --help`. -5. Run package and workflow-contract tests. +5. Run package and workflow-contract tests, including the offline one-model catalog preflight and Compose authority separation. 6. Inspect the lockfile and transitive dependency change. 7. Remove the temporary write-capable bootstrap workflow. 8. Obtain normal exact-head security and review evidence. diff --git a/docs/research/2026-08-07-opencode-commercial-development-loop-standards.md b/docs/research/2026-08-07-opencode-commercial-development-loop-standards.md index e4f81c0a..a39cdf91 100644 --- a/docs/research/2026-08-07-opencode-commercial-development-loop-standards.md +++ b/docs/research/2026-08-07-opencode-commercial-development-loop-standards.md @@ -25,10 +25,14 @@ The OWASP Top 10 for LLM Applications identifies prompt injection, sensitive-inf ## OpenCode and NVIDIA provider boundary -OpenCode exposes a non-interactive `run` command and provider/model configuration. LifeOS uses one exact reviewed `opencode-ai` package version and verifies both the installed version and command contract. Auto-update and sharing are disabled. The model receives a private configuration and a source archive without `.git`; Bash is denied by default except for reviewed `pnpm`, `node`, `python3`, `grep`, `rg`, `find`, `ls`, and `cat` command patterns, while web-fetch, web-search, and external-directory access are denied. The prompt is attached from a private file instead of carrying issue text in process arguments (Anomaly, 2026). +OpenCode exposes a non-interactive `run` command and provider/model configuration. LifeOS uses one exact reviewed `opencode-ai` package version and verifies both the installed version and command contract. Auto-update, sharing, Models.dev refresh, and project-local configuration discovery are disabled; reviewed workspace instruction files are then loaded explicitly. The private configuration enables only NVIDIA, registers the reviewed identifier in `provider.nvidia.models`, whitelists it, pins primary and small-model work to that label, and requires `opencode models nvidia` to return exactly that fully qualified label before the bridge starts. Explicit registration avoids dependence on whether the identifier is present in the binary's bundled snapshot, so no provider `/v1/models` proxy is needed. The model receives a private configuration and a source archive without `.git`; Bash is denied by default except for reviewed `pnpm`, `node`, `python3`, `grep`, `rg`, `find`, `ls`, and `cat` command patterns, while web-fetch, web-search, and external-directory access are denied. The prompt is attached from a private file instead of carrying issue text in process arguments (Anomaly, 2026). NVIDIA NIM exposes hosted OpenAI-compatible inference authenticated with an API key. `NVIDIA_NIM_API_KEY` is mapped only to a loopback bridge running as `opencode_bridge`. OpenCode runs separately as `opencode_model` with a placeholder API-key value and an allowlisted minimal environment; UID-based `iptables` rules restrict its model-phase egress to the bridge. GitHub, review-agent, deployment, and unrelated repository credentials are absent. Provider availability is evidence, not a deterministic merge prerequisite (NVIDIA Corporation, 2026). +### Docker Compose verification boundary + +Docker documents `--file` as the way to select a Compose configuration and `docker compose config` as parsing, resolving, and rendering the resulting application model; `docker compose up --wait` creates services and waits for them to be running or healthy. LifeOS selects the accepted candidate file explicitly and keeps parsing in a trusted step instead of granting Docker authority to `opencode_model`. Actual PostgreSQL query execution, NATS JetStream monitoring, bounded failure logs, and teardown run in ordinary credential-free pull-request CI, where no NVIDIA or GitHub write credential is present, container images are digest-pinned, and published development ports bind only to loopback (Docker, Inc., 2026a, 2026b, 2026c). + ## Test-time compute allocation ### Strong single-agent baseline @@ -74,6 +78,12 @@ Latency and token use are recorded for cost and capacity review but are not the Anomaly. (2026). _OpenCode documentation_. https://opencode.ai/docs/ +Docker, Inc. (2026a). _docker compose_. https://docs.docker.com/reference/cli/docker/compose/ + +Docker, Inc. (2026b). _docker compose config_. https://docs.docker.com/reference/cli/docker/compose/config/ + +Docker, Inc. (2026c). _docker compose up_. https://docs.docker.com/reference/cli/docker/compose/up/ + GitHub. (2026a). _Security hardening for GitHub Actions_. https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions GitHub. (2026b). _Reusing workflows_. https://docs.github.com/en/actions/using-workflows/reusing-workflows diff --git a/docs/superpowers/plans/2026-08-07-opencode-commercial-development-loop.md b/docs/superpowers/plans/2026-08-07-opencode-commercial-development-loop.md index 4efa4c05..b5bd5171 100644 --- a/docs/superpowers/plans/2026-08-07-opencode-commercial-development-loop.md +++ b/docs/superpowers/plans/2026-08-07-opencode-commercial-development-loop.md @@ -194,7 +194,12 @@ Add a workflow-contract test that rejects: - mutable GitHub Action tags; - missing CLI-version verification; - OpenCode invocation with GitHub credentials; -- provider credentials outside the one model step. +- provider credentials outside the one bridge step; +- provider-side `/v1/models` discovery instead of one offline, explicitly whitelisted NVIDIA catalog entry; +- project-local OpenCode configuration that can override the private provider boundary; +- Docker commands executed as the isolated model user; +- Compose parsing that does not select the accepted candidate file explicitly; +- missing pull-request CI startup, health probes, diagnostics, or teardown for the Compose infrastructure. ### GREEN @@ -247,14 +252,16 @@ Workflow sequence: 2. bounded issue evidence collection; 3. issue selection and prompt creation; 4. UUIDv4 branch creation from exact main SHA; -5. loopback bridge invocation with the NVIDIA credential, followed by OpenCode with only a placeholder provider key and no GitHub credential; -6. deterministic diff validation; -7. repository tests selected from changed packages plus root gates; -8. base-SHA recheck; -9. commit and push the bounded branch; -10. create a draft pull request; -11. publish a sanitized receipt; -12. clean temporary files. +5. disable project-local OpenCode configuration, explicitly reload reviewed repository instructions, register and whitelist the selected NVIDIA model independently of the bundled snapshot, and validate the effective catalog offline; +6. loopback bridge invocation with the NVIDIA credential, followed by OpenCode with only a placeholder provider key and no GitHub credential; +7. deterministic diff validation; +8. credential-free repository format, lint, typecheck, test, and build gates under the isolated model account; +9. explicit candidate-file Compose parsing in a separate trusted step without granting the model account Docker-socket authority; +10. base-SHA recheck; +11. commit and push the bounded branch; +12. create a draft pull request whose normal CI boots digest-pinned Compose infrastructure, executes a PostgreSQL query, and validates NATS JetStream before validation can pass; +13. publish a sanitized receipt that distinguishes model-catalog configuration failure from provider unavailability; +14. clean temporary files. Provider absence or failure ends with a successful sanitized unavailable receipt and no branch push. diff --git a/docs/superpowers/specs/2026-08-07-opencode-commercial-development-loop-design.md b/docs/superpowers/specs/2026-08-07-opencode-commercial-development-loop-design.md index 0a8a3d93..0a042976 100644 --- a/docs/superpowers/specs/2026-08-07-opencode-commercial-development-loop-design.md +++ b/docs/superpowers/specs/2026-08-07-opencode-commercial-development-loop-design.md @@ -135,7 +135,7 @@ Initial hard limits: ## OpenCode provider boundary -The workflow installs one exact OpenCode package version recorded in `pnpm-lock.yaml` and verifies `opencode --version` before use. It does not use an unpinned installer script, mutable action tag, or floating package version. +The workflow installs one exact OpenCode package version recorded in `pnpm-lock.yaml` and verifies `opencode --version` before use. It does not use an unpinned installer script, mutable action tag, or floating package version. Project-local configuration discovery is disabled, reviewed workspace instructions are loaded explicitly, and the private provider configuration enables only NVIDIA, registers the selected identifier in `provider.nvidia.models`, pins primary and small-model work to that label, whitelists exactly that model, disables catalog refresh, and requires `opencode models nvidia` to return the one fully qualified label before any provider credential bridge starts. Explicit registration removes dependence on the binary's bundled Models.dev snapshot. The bridge therefore needs no `/v1/models` route; the subsequent bounded completion request remains the live provider/model availability check. Catalog failure is classified as `invalid_configuration`, not provider failure. A loopback NVIDIA credential bridge runs as `opencode_bridge` and alone receives `secrets.NVIDIA_NIM_API_KEY`. It forwards bounded chat-completion requests to NVIDIA NIM. @@ -151,6 +151,8 @@ UID-based `iptables` rules deny other model-process egress during model executio The process does not receive `GITHUB_TOKEN`, `GH_TOKEN`, `COPILOT_GITHUB_TOKEN`, browser credentials, review-agent secrets, deployment credentials, or unrelated repository secrets. Provider absence or outage produces `provider_unavailable`; it does not make the deterministic audit or merge drain fail. +Repository format, lint, typecheck, test, and build commands run as `opencode_model` without Docker-socket authority. After those commands pass, a separate trusted step selects the already diff-accepted candidate's exact Compose file with `--file`, sets its path-resolution base with `--project-directory`, and runs `docker compose config --quiet`. The model user is never added to the Docker group. Normal pull-request CI separately starts digest-pinned PostgreSQL and NATS Compose services with `docker compose up --wait`, proves PostgreSQL query execution with `SELECT 1`, validates the NATS JetStream `/jsz` response, emits bounded diagnostics on failure, and always removes containers and volumes. Published development ports bind only to loopback (Docker, Inc., 2026a, 2026b, 2026c). + ## Test-time compute policy A strong single-model OpenCode route is the mandatory baseline. The initial implementation profile uses one model and one bounded run. The package reserves explicit versioned fields for: @@ -246,7 +248,9 @@ Deterministic tests cover: - workflow, branch-protection, release, deployment, and destructive-operation attempts; - a realistic buyer-gap fixture that produces a bounded application/test/documentation diff; - provider missing/outage behavior; +- exact offline NVIDIA catalog selection without provider-side model discovery; - exact package/action pins and workflow permission separation; +- Compose parsing outside the isolated model account plus credential-free runtime health probes; - draft-only pull-request creation; - credential-free receipt serialization; - route-versus-orchestration ablation arithmetic without live-provider dependence. @@ -276,6 +280,12 @@ Fugu reports dynamic selection between direct and coordinated expert solutions ( Anomaly. (2026). _OpenCode documentation_. https://opencode.ai/docs/ +Docker, Inc. (2026a). _docker compose_. https://docs.docker.com/reference/cli/docker/compose/ + +Docker, Inc. (2026b). _docker compose config_. https://docs.docker.com/reference/cli/docker/compose/config/ + +Docker, Inc. (2026c). _docker compose up_. https://docs.docker.com/reference/cli/docker/compose/up/ + GitHub. (2026a). _Security hardening for GitHub Actions_. https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions GitHub. (2026b). _Reuse workflows_. https://docs.github.com/en/actions/using-workflows/reusing-workflows diff --git a/packages/commercial-development-agent/src/workflow-contract.test.mjs b/packages/commercial-development-agent/src/workflow-contract.test.mjs index 5e124fd9..dc6d98df 100644 --- a/packages/commercial-development-agent/src/workflow-contract.test.mjs +++ b/packages/commercial-development-agent/src/workflow-contract.test.mjs @@ -1,22 +1,58 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; import { describe, expect, it } from 'vitest'; const WORKFLOW_PATH = resolve( import.meta.dirname, '../../../.github/workflows/opencode-commercial-development.yml', ); +const CI_WORKFLOW_PATH = resolve( + import.meta.dirname, + '../../../.github/workflows/ci.yml', +); +const COMPOSE_PATH = resolve(import.meta.dirname, '../../../compose.yaml'); const PACKAGE_PATH = resolve(import.meta.dirname, '../package.json'); const workflow = readFileSync(WORKFLOW_PATH, 'utf8'); +const ciWorkflow = readFileSync(CI_WORKFLOW_PATH, 'utf8'); +const compose = readFileSync(COMPOSE_PATH, 'utf8'); const packageJson = JSON.parse(readFileSync(PACKAGE_PATH, 'utf8')); +const linuxX64Test = + process.platform === 'linux' && process.arch === 'x64' ? it : it.skip; /** Returns one named workflow step including its body but not the next step. */ -function step(name) { +function namedStep(source, name) { const marker = ` - name: ${name}\n`; - const start = workflow.indexOf(marker); + const start = source.indexOf(marker); + expect(start).toBeGreaterThanOrEqual(0); + const next = source.indexOf('\n - name: ', start + marker.length); + return source.slice(start, next === -1 ? source.length : next); +} + +/** Returns one named OpenCode workflow step. */ +function step(name) { + return namedStep(workflow, name); +} + +/** Returns one top-level CI job including its body but not the next job. */ +function ciJob(name) { + const marker = ` ${name}:\n`; + const start = ciWorkflow.indexOf(marker); expect(start).toBeGreaterThanOrEqual(0); - const next = workflow.indexOf('\n - name: ', start + marker.length); - return workflow.slice(start, next === -1 ? workflow.length : next); + const remainder = ciWorkflow.slice(start + marker.length); + const next = remainder.search(/^ [a-z][a-z0-9_]*:\n/mu); + return ciWorkflow.slice( + start, + next === -1 ? ciWorkflow.length : start + marker.length + next, + ); } describe('OpenCode commercial development workflow contract', () => { @@ -139,6 +175,103 @@ describe('OpenCode commercial development workflow contract', () => { expect(model).not.toContain('GH_TOKEN'); }); + it('uses one offline explicit NVIDIA model catalog instead of provider model discovery', () => { + const workspace = step('Prepare disposable model workspace'); + expect(workspace).toContain("'enabled_providers': ['nvidia']"); + expect(workspace).toContain("'model': model_label"); + expect(workspace).toContain("'small_model': model_label"); + expect(workspace).toContain("'whitelist': [model_id]"); + expect(workspace).toContain("'models': {model_id: {'name': model_id}}"); + expect(workspace).toContain("model_workspace_path / 'AGENTS.md'"); + expect(workspace).toContain("model_workspace_path / 'CLAUDE.md'"); + expect(workspace).toContain("'instructions': instruction_paths"); + expect(workspace).toContain("model_label.partition('/')"); + expect(workspace).toContain("provider_id != 'nvidia'"); + + const catalog = step('Validate the explicit OpenCode model catalog'); + expect(catalog).toContain('sudo -u opencode_model env -i'); + expect(catalog).toContain('OPENCODE_DISABLE_MODELS_FETCH=true'); + expect(catalog).toContain('OPENCODE_DISABLE_PROJECT_CONFIG=true'); + expect(catalog).toContain('opencode models nvidia'); + expect(catalog).toContain('test "$catalog" = "$2"'); + expect(catalog).not.toContain('NVIDIA_NIM_API_KEY'); + expect(catalog).not.toContain('/v1/models'); + + const model = step('Run one bounded OpenCode implementation'); + expect(model).toContain('OPENCODE_DISABLE_MODELS_FETCH=true'); + expect(model).toContain('OPENCODE_DISABLE_PROJECT_CONFIG=true'); + }); + + linuxX64Test( + 'registers a NVIDIA model absent from the bundled OpenCode catalog without discovery', + () => { + const temporaryRoot = mkdtempSync( + join(tmpdir(), 'life-os-opencode-catalog-'), + ); + try { + const modelId = 'cwl/contract-probe-model-v1'; + const modelLabel = `nvidia/${modelId}`; + const loopbackProbeValue = modelLabel; + expect(loopbackProbeValue).not.toHaveLength(0); + const opencodePackage = realpathSync( + resolve(import.meta.dirname, '../node_modules/opencode-ai'), + ); + const executable = resolve( + dirname(opencodePackage), + 'opencode-linux-x64/bin/opencode', + ); + const directories = Object.fromEntries( + ['home', 'cache', 'config', 'data', 'state'].map((name) => { + const path = resolve(temporaryRoot, name); + mkdirSync(path, { mode: 0o700 }); + return [name, path]; + }), + ); + const config = JSON.stringify({ + enabled_providers: ['nvidia'], + model: modelLabel, + small_model: modelLabel, + provider: { + nvidia: { + whitelist: [modelId], + models: { [modelId]: { name: modelId } }, + options: { + baseURL: 'http://127.0.0.1:8765/v1', + apiKey: loopbackProbeValue, + }, + }, + }, + }); + const configPath = resolve(directories.home, 'opencode.json'); + writeFileSync(configPath, config, { mode: 0o600 }); + + const result = spawnSync(executable, ['models', 'nvidia'], { + cwd: resolve(import.meta.dirname, '../../..'), + encoding: 'utf8', + timeout: 30_000, + env: { + HOME: directories.home, + PATH: process.env.PATH ?? '/usr/bin:/bin', + XDG_CACHE_HOME: directories.cache, + XDG_CONFIG_HOME: directories.config, + XDG_DATA_HOME: directories.data, + XDG_STATE_HOME: directories.state, + OPENCODE_CONFIG: configPath, + OPENCODE_DISABLE_AUTOUPDATE: 'true', + OPENCODE_DISABLE_MODELS_FETCH: 'true', + OPENCODE_DISABLE_PROJECT_CONFIG: 'true', + NVIDIA_API_KEY: loopbackProbeValue, + }, + }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.trim()).toBe(modelLabel); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } + }, + ); + it('isolates model writes from git, trusted policy, and trusted verifier authority', () => { const workspace = step('Prepare disposable model workspace'); expect(workspace).toContain('MODEL_WORKSPACE'); @@ -229,6 +362,54 @@ describe('OpenCode commercial development workflow contract', () => { expect(verification).not.toContain('NVIDIA_API_KEY'); expect(verification).not.toContain('GH_TOKEN'); expect(verification).not.toContain('GITHUB_TOKEN'); + expect(verification).not.toContain('docker compose'); + + const compose = step( + 'Validate Compose configuration through trusted boundary', + ); + expect(compose).toContain('docker compose'); + expect(compose).toContain('--file "$MODEL_WORKSPACE/compose.yaml"'); + expect(compose).toContain('--project-directory "$MODEL_WORKSPACE"'); + expect(compose).toContain('config --quiet'); + expect(compose).not.toContain('sudo -u opencode_model'); + expect(compose).not.toContain('${{ secrets.'); + }); + + it('boots and probes Compose services in credential-free pull-request CI', () => { + const runtimeJob = ciJob('compose_runtime'); + const validateJob = ciJob('validate'); + expect(validateJob).toContain('needs: compose_runtime'); + expect(runtimeJob).not.toContain('secrets.'); + expect(runtimeJob).not.toContain('GH_TOKEN'); + expect(runtimeJob).not.toContain('GITHUB_TOKEN'); + expect(runtimeJob).not.toMatch(/permissions:\s*\n\s+[^\n]+:\s*write/u); + + const runtime = namedStep( + runtimeJob, + 'Start and probe Compose infrastructure', + ); + expect(runtime).toContain( + 'docker compose up --detach --wait --wait-timeout 90', + ); + expect(runtime).toContain( + "docker compose exec --no-TTY postgres psql -U lifeos -d lifeos -v ON_ERROR_STOP=1 -tAc 'SELECT 1'", + ); + expect(runtime).toContain('http://127.0.0.1:8222/jsz'); + expect(runtime).toContain('jq -e \'(.streams | type) == "number"'); + expect(runtime).toContain( + 'docker compose logs --no-color --timestamps --tail 200 postgres nats', + ); + expect(runtime).toContain('docker compose down --volumes --remove-orphans'); + expect(compose).toMatch( + /image: postgres:17\.10-alpine@sha256:[a-f0-9]{64}/u, + ); + expect(compose).toMatch(/image: nats:2\.11\.6-alpine@sha256:[a-f0-9]{64}/u); + expect(compose).toContain("'127.0.0.1:5432:5432'"); + expect(compose).toContain("'127.0.0.1:4222:4222'"); + expect(compose).toContain("'127.0.0.1:8222:8222'"); + expect(compose).not.toContain("- '5432:5432'"); + expect(compose).not.toContain("- '4222:4222'"); + expect(compose).not.toContain("- '8222:8222'"); }); it('materializes and stages only the accepted evidence projection', () => { @@ -321,10 +502,22 @@ describe('OpenCode commercial development workflow contract', () => { const receipt = step('Compose credential-free development receipt'); expect(receipt).toContain( - `{'name': 'credential_bridge', 'status': 'skipped' if not selected else 'passed' if bridge_reason == 'completed' else 'failed'}`, + 'MODEL_CATALOG_OUTCOME: ${{ steps.model_catalog.outcome }}', + ); + expect(receipt).toContain( + `{'name': 'model_catalog', 'status': 'skipped' if not selected or open_prs != '0' else 'passed' if model_catalog_outcome == 'success' else 'failed'}`, + ); + expect(receipt).toContain( + `{'name': 'credential_bridge', 'status': 'skipped' if not selected or open_prs != '0' or model_catalog_outcome != 'success' else 'passed' if bridge_reason == 'completed' else 'failed'}`, + ); + expect(receipt).toContain( + `{'name': 'provider_run', 'status': 'skipped' if not selected or open_prs != '0' or model_catalog_outcome != 'success' or bridge_reason != 'completed' else 'passed' if model_reason == 'completed' else 'failed'}`, + ); + expect(receipt).toContain( + `{'name': 'diff_policy', 'status': 'skipped' if not selected or open_prs != '0' or model_catalog_outcome != 'success' or bridge_reason != 'completed' or model_reason != 'completed' else 'passed' if diff_accepted else 'failed'}`, ); expect(receipt).toContain( - `{'name': 'provider_run', 'status': 'skipped' if not selected or bridge_reason != 'completed' else 'passed' if model_reason == 'completed' else 'failed'}`, + "status, reason = 'failed', 'invalid_configuration'", ); }); diff --git a/packages/commercial-development-agent/vitest.config.mjs b/packages/commercial-development-agent/vitest.config.mjs index d0855f44..0525007d 100644 --- a/packages/commercial-development-agent/vitest.config.mjs +++ b/packages/commercial-development-agent/vitest.config.mjs @@ -2,6 +2,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + testTimeout: 45_000, coverage: { provider: 'v8', include: ['src/**/*.mjs'], diff --git a/packages/commercial-readiness/package.json b/packages/commercial-readiness/package.json index 3d942326..756d5efc 100644 --- a/packages/commercial-readiness/package.json +++ b/packages/commercial-readiness/package.json @@ -4,9 +4,9 @@ "private": true, "type": "module", "scripts": { - "build": "node --check src/cli.mjs && node --check src/github-client.mjs", - "lint": "node --check src/schema.mjs && node --check src/audit.mjs && node --check src/pr-gate.mjs && node --check src/render.mjs && node --check src/github-client.mjs && node --check src/cli.mjs", + "build": "node --check src/cli.mjs && node --check src/github-client.mjs && node --check src/buyer-gaps.mjs && node --check src/buyer-gap-cli.mjs", + "lint": "node --check src/schema.mjs && node --check src/audit.mjs && node --check src/pr-gate.mjs && node --check src/render.mjs && node --check src/github-client.mjs && node --check src/cli.mjs && node --check src/buyer-gaps.mjs && node --check src/buyer-gap-cli.mjs", "test": "node --test src/*.test.mjs", - "typecheck": "node --check src/schema.mjs && node --check src/audit.mjs && node --check src/pr-gate.mjs && node --check src/render.mjs && node --check src/github-client.mjs && node --check src/cli.mjs" + "typecheck": "node --check src/schema.mjs && node --check src/audit.mjs && node --check src/pr-gate.mjs && node --check src/render.mjs && node --check src/github-client.mjs && node --check src/cli.mjs && node --check src/buyer-gaps.mjs && node --check src/buyer-gap-cli.mjs" } } diff --git a/packages/commercial-readiness/src/audit.mjs b/packages/commercial-readiness/src/audit.mjs index 4ee989de..be5b6744 100644 --- a/packages/commercial-readiness/src/audit.mjs +++ b/packages/commercial-readiness/src/audit.mjs @@ -1,5 +1,6 @@ import { lstat, readFile, realpath } from 'node:fs/promises'; import { resolve, sep } from 'node:path'; +import { attachBuyerGapEvidence } from './buyer-gaps.mjs'; import { MATURITY_LEVELS, MATURITY_RANK } from './schema.mjs'; const REPORT_SCHEMA = 'life-os.commercial-readiness-report.v1'; @@ -117,9 +118,27 @@ function missingEvidenceForTarget(capability, evidenceResults) { ].sort(); } +/** + * Evaluates configured capability maturity against repository evidence. + * + * When `buyerGapEvidence` is undefined, this preserves the legacy v1 report: + * `summary.unresolved_gaps` is the count of capability-evidence gaps and no + * canonical buyer-gap collections or counts are added. When buyer-gap evidence + * is provided, capability maturity and `summary.unresolved_gaps` remain intact + * while `attachBuyerGapEvidence` adds separate unresolved/resolved/unknown + * canonical buyer-gap collections and their summary counts. + * + * @param {object} manifest validated capability manifest to evaluate + * @param {object} options evaluation inputs + * @param {string} options.rootDir repository root containing evidence paths + * @param {string} options.generatedAt ISO-compatible report timestamp + * @param {string} options.commitSha exact audited commit SHA + * @param {object|undefined} options.buyerGapEvidence optional canonical buyer-gap evaluation + * @returns {Promise} immutable-input-derived commercial readiness report + */ export async function evaluateCapabilities( manifest, - { rootDir, generatedAt, commitSha }, + { rootDir, generatedAt, commitSha, buyerGapEvidence }, ) { if (typeof rootDir !== 'string' || !rootDir) throw new Error('Repository root is required'); @@ -196,7 +215,7 @@ export async function evaluateCapabilities( weightedTarget += targetRank * capability.customer_impact; } - return { + const report = { schema: REPORT_SCHEMA, generated_at: new Date(generatedAt).toISOString(), commit_sha: commitSha.toLowerCase(), @@ -212,4 +231,8 @@ export async function evaluateCapabilities( capabilities, gaps, }; + + return buyerGapEvidence === undefined + ? report + : attachBuyerGapEvidence(report, buyerGapEvidence); } diff --git a/packages/commercial-readiness/src/buyer-gap-audit.test.mjs b/packages/commercial-readiness/src/buyer-gap-audit.test.mjs new file mode 100644 index 00000000..57032275 --- /dev/null +++ b/packages/commercial-readiness/src/buyer-gap-audit.test.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { evaluateCapabilities } from './audit.mjs'; + +const manifest = { + capabilities: [ + { + id: 'planning.durable-data', + outcome: 'Durable planning works.', + target_maturity: 'production', + customer_impact: 5, + risk: 5, + acquisition_impact: 5, + effort: 1, + dependencies: [], + tracking_issue: 121, + evidence: [ + { + maturity: 'production', + mode: 'contains', + path: 'evidence.txt', + value: 'durable', + max_bytes: 1024, + }, + ], + }, + ], +}; + +async function evaluate(rootDir, buyerGapEvidence) { + return await evaluateCapabilities(manifest, { + rootDir, + generatedAt: '2026-08-09T11:00:00.000Z', + commitSha: 'a'.repeat(40), + buyerGapEvidence, + }); +} + +describe('evaluateCapabilities with canonical buyer-gap evidence', () => { + it('keeps the configured maturity result byte-for-byte equivalent while adding dimensions', async () => { + const rootDir = await mkdtemp(join(tmpdir(), 'life-os-buyer-gap-audit-')); + await writeFile(join(rootDir, 'evidence.txt'), 'durable', 'utf8'); + + const legacy = await evaluate(rootDir, undefined); + const enriched = await evaluate(rootDir, { + unresolved: [ + { + gap_id: 'today.multi-device-sync', + issue_number: 121, + capability_ids: ['planning.durable-data'], + state: 'open', + resolution: null, + }, + ], + resolved: [], + unknown: [], + }); + + assert.deepEqual(enriched.capabilities, legacy.capabilities); + assert.deepEqual(enriched.gaps, legacy.gaps); + assert.equal( + enriched.summary.weighted_maturity_percent, + legacy.summary.weighted_maturity_percent, + ); + assert.equal(enriched.summary.unresolved_gaps, legacy.summary.unresolved_gaps); + assert.equal(enriched.summary.capability_evidence_gaps, 0); + assert.equal(enriched.summary.unresolved_buyer_gaps, 1); + assert.equal(enriched.summary.unknown_buyer_gap_states, 0); + }); +}); diff --git a/packages/commercial-readiness/src/buyer-gap-cli.mjs b/packages/commercial-readiness/src/buyer-gap-cli.mjs new file mode 100644 index 00000000..6cd820bb --- /dev/null +++ b/packages/commercial-readiness/src/buyer-gap-cli.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +import { lstat, mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { randomUUID } from 'node:crypto'; +import { evaluateCapabilities } from './audit.mjs'; +import { + collectBuyerGapSnapshot, + evaluateBuyerGaps, + validateBuyerGapRegistry, +} from './buyer-gaps.mjs'; +import { GitHubApiClient } from './github-client.mjs'; +import { renderCommercialReadinessIssue } from './render.mjs'; +import { + validateCapabilityManifest, + validateCommercialReadinessPolicy, + validateGitHubSnapshot, +} from './schema.mjs'; + +const FLAG_TO_KEY = Object.freeze({ + '--repository': 'repository', + '--manifest': 'manifest', + '--buyer-gaps': 'buyerGaps', + '--snapshot': 'snapshot', + '--policy': 'policy', + '--root': 'root', + '--output-json': 'outputJson', + '--output-markdown': 'outputMarkdown', +}); +const REQUIRED_KEYS = Object.freeze(Object.values(FLAG_TO_KEY)); + +function invalidCommand() { + throw new Error('Invalid buyer gap audit command'); +} + +/** Parses the fixed, non-shell commercial buyer-gap audit command surface. */ +export function parseBuyerGapArguments(argv) { + if (!Array.isArray(argv)) invalidCommand(); + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const key = FLAG_TO_KEY[argv[index]]; + if (!key || Object.hasOwn(options, key)) invalidCommand(); + const value = argv[index + 1]; + if ( + typeof value !== 'string' || + !value || + value.startsWith('--') || + value.length > 500 || + /[\u0000-\u001f\u007f]/u.test(value) + ) { + invalidCommand(); + } + options[key] = value; + index += 1; + } + if (REQUIRED_KEYS.some((key) => !Object.hasOwn(options, key))) invalidCommand(); + return options; +} + +async function readJson(path, maxBytes = 1024 * 1024) { + const metadata = await lstat(path); + if (metadata.isSymbolicLink() || !metadata.isFile()) { + throw new Error('Buyer gap audit input must be a regular file'); + } + if (metadata.size > maxBytes) { + throw new Error('Buyer gap audit input exceeded the size limit'); + } + try { + return JSON.parse(await readFile(path, 'utf8')); + } catch (error) { + if (error instanceof SyntaxError) throw new Error('Buyer gap audit JSON was invalid'); + throw error; + } +} + +async function writeAtomic(path, content) { + const target = resolve(path); + await mkdir(dirname(target), { recursive: true }); + const temporary = `${target}.${randomUUID()}.tmp`; + await writeFile(temporary, content, { encoding: 'utf8', mode: 0o600 }); + await rename(temporary, target); +} + +/** Runs the capability audit and canonical buyer-gap reconciliation together. */ +export async function runBuyerGapAudit(options, environment = process.env) { + const [manifestValue, registryValue, snapshotValue, policyValue] = await Promise.all([ + readJson(options.manifest), + readJson(options.buyerGaps), + readJson(options.snapshot), + readJson(options.policy), + ]); + const manifest = validateCapabilityManifest(manifestValue); + const registry = validateBuyerGapRegistry(registryValue, manifest); + const snapshot = validateGitHubSnapshot(snapshotValue); + const policy = validateCommercialReadinessPolicy(policyValue); + const token = environment.GITHUB_TOKEN; + if (typeof token !== 'string' || !token.trim()) { + throw new Error('GitHub token is required'); + } + const client = new GitHubApiClient({ token }); + const gapSnapshot = await collectBuyerGapSnapshot( + client, + options.repository, + registry, + snapshot.generated_at, + ); + const buyerGapEvidence = evaluateBuyerGaps(registry, gapSnapshot); + const report = await evaluateCapabilities(manifest, { + rootDir: options.root, + generatedAt: snapshot.generated_at, + commitSha: snapshot.commit_sha, + buyerGapEvidence, + }); + const markdown = renderCommercialReadinessIssue(report, snapshot, { + marker: policy.readiness_issue_marker, + maxGaps: 20, + }); + await Promise.all([ + writeAtomic(options.outputJson, `${JSON.stringify(report, null, 2)}\n`), + writeAtomic(options.outputMarkdown, markdown), + ]); + return report; +} + +async function main(argv = process.argv.slice(2)) { + const options = parseBuyerGapArguments(argv); + const report = await runBuyerGapAudit(options); + console.log( + `audit: ${report.summary.capability_evidence_gaps} capability evidence gap(s), ${report.summary.unresolved_buyer_gaps} canonical buyer gap(s), ${report.summary.unknown_buyer_gap_states} unknown buyer-gap state(s)`, + ); +} + +const invokedPath = process.argv[1]; +if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) { + main().catch((error) => { + console.error( + error instanceof Error ? error.message : 'Buyer gap audit failed', + ); + process.exitCode = 1; + }); +} diff --git a/packages/commercial-readiness/src/buyer-gap-cli.test.mjs b/packages/commercial-readiness/src/buyer-gap-cli.test.mjs new file mode 100644 index 00000000..37d7c4ec --- /dev/null +++ b/packages/commercial-readiness/src/buyer-gap-cli.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { parseBuyerGapArguments } from './buyer-gap-cli.mjs'; + +const validArguments = [ + '--repository', + 'ContextualWisdomLab/life-os', + '--manifest', + 'product/capabilities.json', + '--buyer-gaps', + 'product/buyer-gaps.json', + '--snapshot', + 'evidence/github-snapshot.json', + '--policy', + 'product/commercial-readiness-policy.json', + '--root', + '.', + '--output-json', + 'evidence/commercial-readiness.json', + '--output-markdown', + 'evidence/commercial-readiness.md', +]; + +describe('parseBuyerGapArguments', () => { + it('accepts the fixed bounded workflow surface', () => { + assert.deepEqual(parseBuyerGapArguments(validArguments), { + repository: 'ContextualWisdomLab/life-os', + manifest: 'product/capabilities.json', + buyerGaps: 'product/buyer-gaps.json', + snapshot: 'evidence/github-snapshot.json', + policy: 'product/commercial-readiness-policy.json', + root: '.', + outputJson: 'evidence/commercial-readiness.json', + outputMarkdown: 'evidence/commercial-readiness.md', + }); + }); + + it('rejects unknown, duplicate, missing, and control-character arguments', () => { + for (const argv of [ + validArguments.slice(0, -2), + [...validArguments, '--unknown', 'value'], + [...validArguments, '--root', '.'], + validArguments.map((value, index) => + index === 1 ? 'ContextualWisdomLab/life-os\nother' : value, + ), + ]) { + assert.throws( + () => parseBuyerGapArguments(argv), + /Invalid buyer gap audit command/, + ); + } + }); +}); diff --git a/packages/commercial-readiness/src/buyer-gap-report.test.mjs b/packages/commercial-readiness/src/buyer-gap-report.test.mjs new file mode 100644 index 00000000..035f58f9 --- /dev/null +++ b/packages/commercial-readiness/src/buyer-gap-report.test.mjs @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it } from 'node:test'; +import { attachBuyerGapEvidence } from './buyer-gaps.mjs'; +import { renderCommercialReadinessIssue } from './render.mjs'; + +const repositoryRoot = process.env.LIFE_OS_REPOSITORY_ROOT + ? resolve(process.env.LIFE_OS_REPOSITORY_ROOT) + : resolve(fileURLToPath(new URL('../../../', import.meta.url))); + +async function repositoryFile(path) { + return await readFile(resolve(repositoryRoot, path), 'utf8'); +} + +const baseReport = { + schema: 'life-os.commercial-readiness-report.v1', + generated_at: '2026-08-09T11:00:00.000Z', + commit_sha: 'a'.repeat(40), + summary: { + total_capabilities: 22, + at_target: 22, + unresolved_gaps: 0, + weighted_maturity_percent: 100, + }, + capabilities: [], + gaps: [], +}; + +const snapshot = { + pull_requests: [], +}; + +describe('canonical buyer-gap report', () => { + it('keeps 100 percent configured maturity separate from an open canonical product gap', () => { + const report = attachBuyerGapEvidence(baseReport, { + unresolved: [ + { + gap_id: 'calendar.per-user-credentials', + issue_number: 129, + capability_ids: ['calendar.time-blocking'], + state: 'open', + resolution: null, + }, + ], + resolved: [], + unknown: [], + }); + + assert.equal(report.summary.weighted_maturity_percent, 100); + assert.equal(report.summary.capability_evidence_gaps, 0); + assert.equal(report.summary.unresolved_buyer_gaps, 1); + const markdown = renderCommercialReadinessIssue(report, snapshot, { + marker: '', + maxGaps: 20, + }); + assert.match(markdown, /Configured weighted maturity: \*\*100%\*\*/); + assert.match(markdown, /Capability evidence gaps: \*\*0\*\*/); + assert.match(markdown, /Unresolved canonical buyer gaps: \*\*1\*\*/); + assert.match(markdown, /calendar\.per-user-credentials/); + assert.match(markdown, /#129/); + assert.doesNotMatch( + markdown, + /Unresolved canonical buyer gaps: \*\*0\*\*/, + ); + assert.doesNotMatch( + markdown, + /No registered canonical buyer gaps remain/, + ); + }); + + it('renders unknown canonical issue state explicitly instead of claiming exhaustion', () => { + const report = attachBuyerGapEvidence(baseReport, { + unresolved: [], + resolved: [], + unknown: [ + { + gap_id: 'plugins.runtime-delivery', + issue_number: 130, + capability_ids: ['integrations.plugin-surface'], + state: 'unknown', + resolution: null, + }, + ], + }); + const markdown = renderCommercialReadinessIssue(report, snapshot, { + marker: '', + }); + assert.match(markdown, /Unknown canonical buyer-gap states: \*\*1\*\*/); + assert.match(markdown, /state unknown/); + assert.doesNotMatch(markdown, /No registered canonical buyer gaps remain/); + }); + + it('wires the registry into the live commercial-readiness workflow', async () => { + const workflow = await repositoryFile( + '.github/workflows/commercial-readiness.yml', + ); + assert.match(workflow, /buyer-gap-cli\.mjs/); + assert.match(workflow, /--buyer-gaps product\/buyer-gaps\.json/); + assert.match(workflow, /issues:\s*read/); + assert.match(workflow, /GITHUB_TOKEN:\s*\$\{\{ github\.token \}\}/); + assert.doesNotMatch(workflow, /secrets:\s*inherit/); + }); +}); diff --git a/packages/commercial-readiness/src/buyer-gap-validation.test.mjs b/packages/commercial-readiness/src/buyer-gap-validation.test.mjs new file mode 100644 index 00000000..f487b3bb --- /dev/null +++ b/packages/commercial-readiness/src/buyer-gap-validation.test.mjs @@ -0,0 +1,175 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + attachBuyerGapEvidence, + validateBuyerGapSnapshot, +} from './buyer-gaps.mjs'; + +function snapshot(overrides = {}) { + return { + schema: 'life-os.commercial-buyer-gap-snapshot.v1', + repository: 'ContextualWisdomLab/life-os', + generated_at: '2026-08-09T11:00:00.000Z', + issues: [ + { + number: 55, + state: 'closed', + state_reason: 'completed', + labels: [], + }, + ], + ...overrides, + }; +} + +function readinessReport() { + return { + schema: 'life-os.commercial-readiness-report.v1', + generated_at: '2026-08-09T11:00:00.000Z', + commit_sha: 'a'.repeat(40), + summary: { + total_capabilities: 22, + at_target: 22, + unresolved_gaps: 0, + weighted_maturity_percent: 100, + }, + capabilities: [], + gaps: [], + }; +} + +describe('validateBuyerGapSnapshot', () => { + it('accepts and freezes a minimal external issue-state projection', () => { + const value = validateBuyerGapSnapshot(snapshot()); + assert.equal(value.repository, 'ContextualWisdomLab/life-os'); + assert.equal(Object.isFrozen(value), true); + assert.equal(Object.isFrozen(value.issues), true); + assert.equal(Object.isFrozen(value.issues[0].labels), true); + }); + + it('rejects raw bodies, duplicate evidence, malformed repositories, timestamps, and oversized collections', () => { + const issue = snapshot().issues[0]; + const invalid = [ + snapshot({ repository: 'https://example.test/repo' }), + snapshot({ generated_at: 2026 }), + snapshot({ generated_at: 'not-a-date' }), + snapshot({ issues: [{ ...issue, body: 'untrusted' }] }), + snapshot({ issues: [issue, issue] }), + snapshot({ + issues: Array.from({ length: 101 }, (_, index) => ({ + number: index + 1, + state: 'open', + state_reason: null, + labels: [], + })), + }), + snapshot({ + issues: [ + { + number: 55, + state: 'open', + state_reason: null, + labels: ['unsafe\nlabel'], + }, + ], + }), + ]; + for (const value of invalid) { + assert.throws( + () => validateBuyerGapSnapshot(value), + /Invalid buyer gap snapshot/, + ); + } + }); +}); + +describe('attachBuyerGapEvidence', () => { + it('preserves configured capability maturity while adding explicit product-gap dimensions', () => { + const result = attachBuyerGapEvidence(readinessReport(), { + unresolved: [ + { + gap_id: 'data.portability-completion', + issue_number: 55, + capability_ids: ['data.portability-rights'], + state: 'open', + resolution: null, + }, + ], + resolved: [], + unknown: [], + }); + + assert.equal(result.summary.weighted_maturity_percent, 100); + assert.equal(result.summary.unresolved_gaps, 0); + assert.equal(result.summary.capability_evidence_gaps, 0); + assert.equal(result.summary.unresolved_buyer_gaps, 1); + assert.equal(result.summary.unknown_buyer_gap_states, 0); + assert.equal(result.buyer_gaps[0].issue_number, 55); + }); + + it('rejects malformed buyer-gap evidence before reading collection members', () => { + const invalidEvidence = [ + null, + {}, + { unresolved: [], resolved: [], unknown: 'not-an-array' }, + { unresolved: [], resolved: 'not-an-array', unknown: [] }, + { unresolved: 'not-an-array', resolved: [], unknown: [] }, + ]; + + for (const evidence of invalidEvidence) { + assert.throws( + () => attachBuyerGapEvidence(readinessReport(), evidence), + /Buyer gap evidence is invalid/, + ); + } + }); + + it('rejects malformed items inside every buyer-gap evidence collection', () => { + const validItem = { + gap_id: 'data.portability-completion', + issue_number: 55, + capability_ids: ['data.portability-rights'], + state: 'open', + resolution: null, + }; + const invalidItems = [ + { ...validItem, state: 'closed' }, + { ...validItem, issue_number: 0 }, + { ...validItem, capability_ids: [] }, + { ...validItem, resolution: 'completed' }, + { ...validItem, unexpected: true }, + ]; + + for (const item of invalidItems) { + assert.throws( + () => + attachBuyerGapEvidence(readinessReport(), { + unresolved: [item], + resolved: [], + unknown: [], + }), + /Buyer gap evidence is invalid/, + ); + } + + assert.throws( + () => + attachBuyerGapEvidence(readinessReport(), { + unresolved: [], + resolved: [{ ...validItem, state: 'closed', resolution: null }], + unknown: [], + }), + /Buyer gap evidence is invalid/, + ); + + assert.throws( + () => + attachBuyerGapEvidence(readinessReport(), { + unresolved: [], + resolved: [], + unknown: [{ ...validItem, state: 'unknown', resolution: 'completed' }], + }), + /Buyer gap evidence is invalid/, + ); + }); +}); diff --git a/packages/commercial-readiness/src/buyer-gaps.mjs b/packages/commercial-readiness/src/buyer-gaps.mjs new file mode 100644 index 00000000..f93b59fe --- /dev/null +++ b/packages/commercial-readiness/src/buyer-gaps.mjs @@ -0,0 +1,488 @@ +const REGISTRY_SCHEMA = 'life-os.commercial-buyer-gaps.v1'; +const SNAPSHOT_SCHEMA = 'life-os.commercial-buyer-gap-snapshot.v1'; +const GAP_ID_PATTERN = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$/; +const CAPABILITY_ID_PATTERN = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$/; +const REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +const MAX_GAPS = 100; +const MAX_CAPABILITIES_PER_GAP = 25; +const MAX_LABELS = 50; +const MAX_LABEL_LENGTH = 100; + +function isPlainObject(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function exactKeys(value, allowed) { + return isPlainObject(value) && Object.keys(value).every((key) => allowed.has(key)); +} + +function failRegistry(detail = '') { + throw new Error(`Invalid buyer gap registry${detail ? `: ${detail}` : ''}`); +} + +function failSnapshot(detail = '') { + throw new Error(`Invalid buyer gap snapshot${detail ? `: ${detail}` : ''}`); +} + +function normalizeGapId(value) { + if (typeof value !== 'string' || !GAP_ID_PATTERN.test(value) || value.length > 100) { + failRegistry('invalid gap id'); + } + return value; +} + +function normalizeIssueNumber(value, fail = failRegistry) { + if (!Number.isSafeInteger(value) || value <= 0) { + fail('invalid issue number'); + } + return value; +} + +function manifestCapabilityIds(manifest) { + if (!isPlainObject(manifest) || !Array.isArray(manifest.capabilities)) { + failRegistry('invalid capability manifest'); + } + const ids = new Set(); + for (const capability of manifest.capabilities) { + const id = capability?.id; + if (typeof id !== 'string' || !CAPABILITY_ID_PATTERN.test(id)) { + failRegistry('invalid capability manifest'); + } + ids.add(id); + } + return ids; +} + +/** + * Validates repository-owned buyer-gap policy independently from capability + * maturity. GitHub issue title/body text is never executable product policy. + */ +export function validateBuyerGapRegistry(value, manifest) { + if ( + !exactKeys(value, new Set(['schema', 'gaps'])) || + value.schema !== REGISTRY_SCHEMA || + !Array.isArray(value.gaps) || + value.gaps.length === 0 || + value.gaps.length > MAX_GAPS + ) { + failRegistry(); + } + + const knownCapabilities = manifestCapabilityIds(manifest); + const gapIds = new Set(); + const issueNumbers = new Set(); + const gaps = value.gaps.map((entry) => { + if (!exactKeys(entry, new Set(['gap_id', 'issue_number', 'capability_ids']))) { + failRegistry('invalid gap entry'); + } + const gapId = normalizeGapId(entry.gap_id); + const issueNumber = normalizeIssueNumber(entry.issue_number); + if (gapIds.has(gapId)) failRegistry('duplicate gap id'); + if (issueNumbers.has(issueNumber)) failRegistry('duplicate canonical issue'); + gapIds.add(gapId); + issueNumbers.add(issueNumber); + + if ( + !Array.isArray(entry.capability_ids) || + entry.capability_ids.length === 0 || + entry.capability_ids.length > MAX_CAPABILITIES_PER_GAP + ) { + failRegistry('invalid capability collection'); + } + const capabilityIds = entry.capability_ids.map((capabilityId) => { + if ( + typeof capabilityId !== 'string' || + !CAPABILITY_ID_PATTERN.test(capabilityId) || + !knownCapabilities.has(capabilityId) + ) { + failRegistry('unknown or invalid capability id'); + } + return capabilityId; + }); + if (new Set(capabilityIds).size !== capabilityIds.length) { + failRegistry('duplicate capability id'); + } + + return Object.freeze({ + gap_id: gapId, + issue_number: issueNumber, + capability_ids: Object.freeze([...capabilityIds]), + }); + }); + + return Object.freeze({ + schema: REGISTRY_SCHEMA, + gaps: Object.freeze(gaps), + }); +} + +function normalizeLabel(value) { + const label = + typeof value === 'string' + ? value + : isPlainObject(value) && typeof value.name === 'string' + ? value.name + : null; + if ( + label === null || + !label.trim() || + label.length > MAX_LABEL_LENGTH || + /[\u0000-\u001f\u007f]/u.test(label) + ) { + failSnapshot('invalid issue label'); + } + return label.trim(); +} + +function normalizeIssueEvidence(value) { + if ( + !exactKeys( + value, + new Set(['number', 'state', 'state_reason', 'labels']), + ) + ) { + failSnapshot('invalid issue evidence'); + } + const number = normalizeIssueNumber(value.number, failSnapshot); + if (!['open', 'closed', 'unknown'].includes(value.state)) { + failSnapshot('invalid issue state'); + } + const stateReason = value.state_reason; + if ( + stateReason !== null && + stateReason !== undefined && + !['completed', 'not_planned', 'reopened'].includes(stateReason) + ) { + failSnapshot('invalid issue state reason'); + } + if (!Array.isArray(value.labels) || value.labels.length > MAX_LABELS) { + failSnapshot('invalid issue labels'); + } + const labels = value.labels.map(normalizeLabel); + if (new Set(labels).size !== labels.length) { + failSnapshot('duplicate issue label'); + } + return Object.freeze({ + number, + state: value.state, + state_reason: stateReason ?? null, + labels: Object.freeze(labels), + }); +} + +/** Validates the minimal live issue-state projection used by buyer-gap policy. */ +export function validateBuyerGapSnapshot(value) { + if ( + !exactKeys( + value, + new Set(['schema', 'repository', 'generated_at', 'issues']), + ) || + value.schema !== SNAPSHOT_SCHEMA || + typeof value.repository !== 'string' || + !REPOSITORY_PATTERN.test(value.repository) || + typeof value.generated_at !== 'string' || + !Number.isFinite(Date.parse(value.generated_at)) || + !Array.isArray(value.issues) || + value.issues.length > MAX_GAPS + ) { + failSnapshot(); + } + const issues = value.issues.map(normalizeIssueEvidence); + const seen = new Set(); + for (const issue of issues) { + if (seen.has(issue.number)) failSnapshot('duplicate issue evidence'); + seen.add(issue.number); + } + return Object.freeze({ + schema: SNAPSHOT_SCHEMA, + repository: value.repository, + generated_at: new Date(value.generated_at).toISOString(), + issues: Object.freeze(issues), + }); +} + +function projectedLabels(rawLabels) { + if (!Array.isArray(rawLabels) || rawLabels.length > MAX_LABELS) return []; + const labels = []; + for (const rawLabel of rawLabels) { + const label = + typeof rawLabel === 'string' + ? rawLabel + : isPlainObject(rawLabel) && typeof rawLabel.name === 'string' + ? rawLabel.name + : null; + if ( + label === null || + !label.trim() || + label.length > MAX_LABEL_LENGTH || + /[\u0000-\u001f\u007f]/u.test(label) + ) { + continue; + } + labels.push(label.trim()); + } + return [...new Set(labels)].sort(); +} + +/** + * Collects only the registered issue states. Individual fetch failures become + * explicit unknown evidence instead of silently resolving a product gap. + */ +export async function collectBuyerGapSnapshot( + client, + repository, + registry, + generatedAt = new Date().toISOString(), +) { + if ( + !client || + typeof client.requestJson !== 'function' || + typeof repository !== 'string' || + !REPOSITORY_PATTERN.test(repository) || + typeof generatedAt !== 'string' || + !Number.isFinite(Date.parse(generatedAt)) || + !registry || + registry.schema !== REGISTRY_SCHEMA || + !Array.isArray(registry.gaps) + ) { + throw new Error('Buyer gap snapshot collection input is invalid'); + } + + const issues = []; + for (const gap of [...registry.gaps].sort( + (left, right) => left.issue_number - right.issue_number, + )) { + try { + const issue = await client.requestJson( + `/repos/${repository}/issues/${gap.issue_number}`, + ); + if (issue?.pull_request) { + issues.push({ + number: gap.issue_number, + state: 'unknown', + state_reason: null, + labels: [], + }); + continue; + } + issues.push({ + number: gap.issue_number, + state: issue?.state === 'open' || issue?.state === 'closed' ? issue.state : 'unknown', + state_reason: + issue?.state_reason === 'completed' || + issue?.state_reason === 'not_planned' || + issue?.state_reason === 'reopened' + ? issue.state_reason + : null, + labels: projectedLabels(issue?.labels), + }); + } catch { + issues.push({ + number: gap.issue_number, + state: 'unknown', + state_reason: null, + labels: [], + }); + } + } + + return validateBuyerGapSnapshot({ + schema: SNAPSHOT_SCHEMA, + repository, + generated_at: generatedAt, + issues, + }); +} + +function resolutionFor(issue) { + const labels = new Set(issue.labels.map((label) => label.toLowerCase())); + if (labels.has('duplicate')) return 'duplicate'; + if (issue.state_reason === 'completed') return 'completed'; + if (issue.state_reason === 'not_planned') return 'not_planned'; + return null; +} + +function gapEvidence(gap, state, resolution = null) { + return { + gap_id: gap.gap_id, + issue_number: gap.issue_number, + capability_ids: [...gap.capability_ids], + state, + resolution, + }; +} + +/** + * Reconciles canonical product policy with bounded live issue state. Open gaps + * remain unresolved; missing or ambiguous evidence remains explicit unknown. + */ +export function evaluateBuyerGaps(registry, snapshot) { + const issues = new Map( + (Array.isArray(snapshot?.issues) ? snapshot.issues : []).map((issue) => [ + issue.number, + issue, + ]), + ); + const unresolved = []; + const resolved = []; + const unknown = []; + + for (const gap of [...registry.gaps].sort((left, right) => + left.gap_id.localeCompare(right.gap_id), + )) { + const issue = issues.get(gap.issue_number); + if (!issue || issue.state === 'unknown') { + unknown.push(gapEvidence(gap, 'unknown')); + continue; + } + if (issue.state === 'open') { + unresolved.push(gapEvidence(gap, 'open')); + continue; + } + const resolution = resolutionFor(issue); + if (issue.state === 'closed' && resolution !== null) { + resolved.push(gapEvidence(gap, 'closed', resolution)); + continue; + } + unknown.push(gapEvidence(gap, 'unknown')); + } + + const byIssue = (left, right) => + left.issue_number - right.issue_number || left.gap_id.localeCompare(right.gap_id); + unresolved.sort(byIssue); + resolved.sort(byIssue); + unknown.sort(byIssue); + return { unresolved, resolved, unknown }; +} + +/** Throws the stable boundary error used for malformed attached gap evidence. */ +function failBuyerGapEvidence() { + throw new Error('Buyer gap evidence is invalid'); +} + +/** + * Validates and freezes one attached gap item for its owning evidence collection. + * The item must use exact buyer-gap keys, bounded identifiers, unique capability + * identifiers, the expected state, and a state-compatible resolution; otherwise + * the stable buyer-gap evidence validation error is thrown. + */ +function normalizeAttachedGapEvidence(value, expectedState) { + if ( + !exactKeys( + value, + new Set(['gap_id', 'issue_number', 'capability_ids', 'state', 'resolution']), + ) || + typeof value.gap_id !== 'string' || + !GAP_ID_PATTERN.test(value.gap_id) || + value.gap_id.length > 100 || + value.state !== expectedState || + !Array.isArray(value.capability_ids) || + value.capability_ids.length === 0 || + value.capability_ids.length > MAX_CAPABILITIES_PER_GAP + ) { + failBuyerGapEvidence(); + } + + const issueNumber = normalizeIssueNumber( + value.issue_number, + failBuyerGapEvidence, + ); + const capabilityIds = value.capability_ids.map((capabilityId) => { + if ( + typeof capabilityId !== 'string' || + !CAPABILITY_ID_PATTERN.test(capabilityId) + ) { + failBuyerGapEvidence(); + } + return capabilityId; + }); + if (new Set(capabilityIds).size !== capabilityIds.length) { + failBuyerGapEvidence(); + } + + const validResolution = + expectedState === 'closed' + ? ['completed', 'not_planned', 'duplicate'].includes(value.resolution) + : value.resolution === null; + if (!validResolution) { + failBuyerGapEvidence(); + } + + return Object.freeze({ + gap_id: value.gap_id, + issue_number: issueNumber, + capability_ids: Object.freeze([...capabilityIds]), + state: expectedState, + resolution: value.resolution, + }); +} + +/** + * Validates the three attached evidence collections as one bounded gap set. + * Each collection is normalized to its required state, total cardinality is + * bounded, and duplicate gap or canonical issue ownership fails closed before + * a readiness report can retain the evidence. + */ +function normalizeBuyerGapEvidence(value) { + if ( + !exactKeys(value, new Set(['unresolved', 'resolved', 'unknown'])) || + !Array.isArray(value.unresolved) || + !Array.isArray(value.resolved) || + !Array.isArray(value.unknown) || + value.unresolved.length > MAX_GAPS || + value.resolved.length > MAX_GAPS || + value.unknown.length > MAX_GAPS || + value.unresolved.length + value.resolved.length + value.unknown.length > MAX_GAPS + ) { + failBuyerGapEvidence(); + } + + const unresolved = value.unresolved.map((item) => + normalizeAttachedGapEvidence(item, 'open'), + ); + const resolved = value.resolved.map((item) => + normalizeAttachedGapEvidence(item, 'closed'), + ); + const unknown = value.unknown.map((item) => + normalizeAttachedGapEvidence(item, 'unknown'), + ); + const gapIds = new Set(); + const issueNumbers = new Set(); + for (const item of [...unresolved, ...resolved, ...unknown]) { + if (gapIds.has(item.gap_id) || issueNumbers.has(item.issue_number)) { + failBuyerGapEvidence(); + } + gapIds.add(item.gap_id); + issueNumbers.add(item.issue_number); + } + + return Object.freeze({ + unresolved: Object.freeze(unresolved), + resolved: Object.freeze(resolved), + unknown: Object.freeze(unknown), + }); +} + +/** Adds buyer-gap evidence without reinterpreting capability maturity. */ +export function attachBuyerGapEvidence(report, evidence) { + if (!isPlainObject(report) || !isPlainObject(report.summary)) { + throw new Error('Commercial readiness report is invalid'); + } + const normalizedEvidence = normalizeBuyerGapEvidence(evidence); + return { + ...report, + summary: { + ...report.summary, + capability_evidence_gaps: report.summary.unresolved_gaps, + unresolved_buyer_gaps: normalizedEvidence.unresolved.length, + unknown_buyer_gap_states: normalizedEvidence.unknown.length, + }, + buyer_gaps: normalizedEvidence.unresolved.map((item) => ({ ...item })), + buyer_gap_unknown: normalizedEvidence.unknown.map((item) => ({ ...item })), + buyer_gap_resolved: normalizedEvidence.resolved.map((item) => ({ ...item })), + }; +} diff --git a/packages/commercial-readiness/src/buyer-gaps.test.mjs b/packages/commercial-readiness/src/buyer-gaps.test.mjs new file mode 100644 index 00000000..fe581255 --- /dev/null +++ b/packages/commercial-readiness/src/buyer-gaps.test.mjs @@ -0,0 +1,325 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + collectBuyerGapSnapshot, + evaluateBuyerGaps, + validateBuyerGapRegistry, + validateBuyerGapSnapshot, +} from './buyer-gaps.mjs'; + +const manifest = Object.freeze({ + schema: 'life-os.capability-manifest.v1', + capabilities: Object.freeze([ + Object.freeze({ id: 'planning.durable-data' }), + Object.freeze({ id: 'today.action-loop' }), + Object.freeze({ id: 'calendar.time-blocking' }), + Object.freeze({ id: 'integrations.plugin-surface' }), + ]), +}); + +function registry(gaps) { + return { + schema: 'life-os.commercial-buyer-gaps.v1', + gaps, + }; +} + +function gap(overrides = {}) { + return { + gap_id: 'today.multi-device-sync', + issue_number: 121, + capability_ids: ['planning.durable-data', 'today.action-loop'], + ...overrides, + }; +} + +function snapshot(issues) { + return { + schema: 'life-os.github-snapshot.v1', + repository: 'ContextualWisdomLab/life-os', + commit_sha: 'a'.repeat(40), + generated_at: '2026-08-09T11:00:00.000Z', + truncated: false, + pull_requests: [], + issues, + }; +} + +describe('validateBuyerGapRegistry', () => { + it('accepts a bounded repository-owned registry and freezes normalized entries', () => { + const result = validateBuyerGapRegistry(registry([gap()]), manifest); + assert.equal(result.schema, 'life-os.commercial-buyer-gaps.v1'); + assert.equal(result.gaps[0].gap_id, 'today.multi-device-sync'); + assert.deepEqual(result.gaps[0].capability_ids, [ + 'planning.durable-data', + 'today.action-loop', + ]); + assert.equal(Object.isFrozen(result), true); + assert.equal(Object.isFrozen(result.gaps), true); + assert.equal(Object.isFrozen(result.gaps[0].capability_ids), true); + }); + + it('rejects duplicate policy ownership, unknown capabilities, and malformed identifiers', () => { + const invalidRegistries = [ + registry([gap(), gap()]), + registry([ + gap(), + gap({ + gap_id: 'calendar.per-user-credentials', + capability_ids: ['calendar.time-blocking'], + }), + ]), + registry([gap({ capability_ids: ['missing.capability'] })]), + registry([gap({ gap_id: '121' })]), + registry([gap({ issue_number: '121' })]), + registry([gap({ capability_ids: [] })]), + registry([ + gap({ capability_ids: ['today.action-loop', 'today.action-loop'] }), + ]), + ]; + + for (const value of invalidRegistries) { + assert.throws( + () => validateBuyerGapRegistry(value, manifest), + /Invalid buyer gap registry/, + ); + } + }); +}); + +describe('validateBuyerGapSnapshot', () => { + it('rejects a non-string generated_at even when Date.parse would coerce it', () => { + assert.throws( + () => + validateBuyerGapSnapshot({ + schema: 'life-os.buyer-gap-snapshot.v1', + repository: 'ContextualWisdomLab/life-os', + generated_at: 2026, + issues: [], + }), + /Invalid buyer gap snapshot/, + ); + }); +}); + +describe('collectBuyerGapSnapshot', () => { + it('retains only bounded registered issue state and makes fetch failure unknown', async () => { + const validated = validateBuyerGapRegistry( + registry([ + gap(), + gap({ + gap_id: 'calendar.per-user-credentials', + issue_number: 129, + capability_ids: ['calendar.time-blocking'], + }), + ]), + manifest, + ); + const requested = []; + const client = { + async requestJson(path) { + requested.push(path); + if (path.endsWith('/121')) { + return { + number: 121, + title: 'untrusted title not retained', + body: 'untrusted body not retained', + state: 'open', + state_reason: null, + labels: [{ name: 'buyer-gap' }], + }; + } + throw new Error('provider unavailable'); + }, + }; + + const result = await collectBuyerGapSnapshot( + client, + 'ContextualWisdomLab/life-os', + validated, + '2026-08-09T11:00:00.000Z', + ); + + assert.deepEqual(requested, [ + '/repos/ContextualWisdomLab/life-os/issues/121', + '/repos/ContextualWisdomLab/life-os/issues/129', + ]); + assert.deepEqual(result.issues, [ + { + number: 121, + state: 'open', + state_reason: null, + labels: ['buyer-gap'], + }, + { number: 129, state: 'unknown', state_reason: null, labels: [] }, + ]); + assert.equal(JSON.stringify(result).includes('untrusted title'), false); + assert.equal(JSON.stringify(result).includes('untrusted body'), false); + }); + + it('rejects a non-string generatedAt before provider access', async () => { + const validated = validateBuyerGapRegistry(registry([gap()]), manifest); + let providerCalled = false; + const client = { + async requestJson() { + providerCalled = true; + return { state: 'open', labels: [] }; + }, + }; + + await assert.rejects( + collectBuyerGapSnapshot( + client, + 'ContextualWisdomLab/life-os', + validated, + 2026, + ), + /Buyer gap snapshot collection input is invalid/, + ); + assert.equal(providerCalled, false); + }); +}); + +describe('evaluateBuyerGaps', () => { + it('keeps an open canonical gap unresolved even when its capabilities are already mature', () => { + const validated = validateBuyerGapRegistry(registry([gap()]), manifest); + const result = evaluateBuyerGaps( + validated, + snapshot([ + { + number: 121, + title: 'Durable Today synchronization', + state: 'open', + state_reason: null, + labels: [], + }, + ]), + ); + + assert.equal(result.unresolved.length, 1); + assert.equal(result.unresolved[0].gap_id, 'today.multi-device-sync'); + assert.equal(result.unresolved[0].issue_number, 121); + assert.equal(result.unresolved[0].state, 'open'); + assert.deepEqual(result.unknown, []); + }); + + it('treats closed completed, duplicate-labeled, and not-planned issues as resolved', () => { + const validated = validateBuyerGapRegistry( + registry([ + gap(), + gap({ + gap_id: 'calendar.per-user-credentials', + issue_number: 129, + capability_ids: ['calendar.time-blocking'], + }), + gap({ + gap_id: 'plugins.runtime-delivery', + issue_number: 130, + capability_ids: ['integrations.plugin-surface'], + }), + ]), + manifest, + ); + const result = evaluateBuyerGaps( + validated, + snapshot([ + { + number: 121, + title: 'Today', + state: 'closed', + state_reason: 'completed', + labels: [], + }, + { + number: 129, + title: 'Calendar', + state: 'closed', + state_reason: null, + labels: ['duplicate'], + }, + { + number: 130, + title: 'Plugin', + state: 'closed', + state_reason: 'not_planned', + labels: [], + }, + ]), + ); + + assert.equal(result.unresolved.length, 0); + assert.equal(result.unknown.length, 0); + assert.deepEqual( + result.resolved.map((item) => [item.gap_id, item.resolution]), + [ + ['today.multi-device-sync', 'completed'], + ['calendar.per-user-credentials', 'duplicate'], + ['plugins.runtime-delivery', 'not_planned'], + ], + ); + }); + + it('fails closed to an explicit unknown state when registered issue evidence is missing', () => { + const validated = validateBuyerGapRegistry(registry([gap()]), manifest); + const result = evaluateBuyerGaps(validated, snapshot([])); + + assert.equal(result.unresolved.length, 0); + assert.equal(result.unknown.length, 1); + assert.deepEqual(result.unknown[0], { + gap_id: 'today.multi-device-sync', + issue_number: 121, + capability_ids: ['planning.durable-data', 'today.action-loop'], + state: 'unknown', + resolution: null, + }); + }); + + it('sorts evidence deterministically and ignores unregistered ordinary issues', () => { + const validated = validateBuyerGapRegistry( + registry([ + gap({ + gap_id: 'plugins.runtime-delivery', + issue_number: 130, + capability_ids: ['integrations.plugin-surface'], + }), + gap(), + ]), + manifest, + ); + const result = evaluateBuyerGaps( + validated, + snapshot([ + { + number: 999, + title: 'Ordinary issue', + state: 'open', + state_reason: null, + labels: [], + }, + { + number: 130, + title: 'Plugin runtime', + state: 'open', + state_reason: null, + labels: [], + }, + { + number: 121, + title: 'Today sync', + state: 'open', + state_reason: null, + labels: [], + }, + ]), + ); + + assert.deepEqual( + result.unresolved.map((item) => item.issue_number), + [121, 130], + ); + assert.equal( + result.unresolved.some((item) => item.issue_number === 999), + false, + ); + }); +}); diff --git a/packages/commercial-readiness/src/exact-head-workflow.test.mjs b/packages/commercial-readiness/src/exact-head-workflow.test.mjs new file mode 100644 index 00000000..28e1d21b --- /dev/null +++ b/packages/commercial-readiness/src/exact-head-workflow.test.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it } from 'node:test'; + +const repositoryRoot = process.env.LIFE_OS_REPOSITORY_ROOT + ? resolve(process.env.LIFE_OS_REPOSITORY_ROOT) + : resolve(fileURLToPath(new URL('../../../', import.meta.url))); + +describe('commercial readiness exact-head contract', () => { + it('binds PR checkout and evidence commit to the contributor head rather than the synthetic merge SHA', async () => { + const workflow = await readFile( + resolve(repositoryRoot, '.github/workflows/commercial-readiness.yml'), + 'utf8', + ); + const sourceExpression = + '\\$\\{\\{ github\\.event\\.pull_request\\.head\\.sha \\|\\| github\\.sha \\}\\}'; + assert.match(workflow, new RegExp(`ref: ${sourceExpression}`)); + assert.match( + workflow, + new RegExp(`--commit "${sourceExpression}"`), + ); + assert.doesNotMatch(workflow, /--commit "\$GITHUB_SHA"/); + }); +}); diff --git a/packages/commercial-readiness/src/render.mjs b/packages/commercial-readiness/src/render.mjs index e244d691..a2e514b9 100644 --- a/packages/commercial-readiness/src/render.mjs +++ b/packages/commercial-readiness/src/render.mjs @@ -31,48 +31,136 @@ function issueLink(number) { : 'untracked'; } +/** + * Renders the capability identifiers attached to one canonical buyer gap. + * Capability IDs describe configured evidence maturity; they do not replace + * the independently reconciled buyer-gap state owned by the canonical issue. + */ +function capabilityList(capabilityIds) { + return (Array.isArray(capabilityIds) ? capabilityIds : []) + .map((id) => `\`${sanitizeUntrustedText(id)}\``) + .join(', '); +} + +/** + * Appends canonical buyer-visible gap evidence to the Markdown report. + * `report.buyer_gaps` and `report.buyer_gap_unknown` come from the explicit + * repository gap registry plus live issue-state reconciliation, independently + * from capability evidence maturity. + */ +function renderCanonicalBuyerGaps(lines, report, maxGaps) { + const hasBuyerEvidence = Number.isSafeInteger( + report.summary?.unresolved_buyer_gaps, + ); + lines.push('## Canonical buyer-visible gaps', ''); + if (!hasBuyerEvidence) { + lines.push( + 'Canonical buyer-gap state was not evaluated in this report; capability maturity must not be interpreted as whole-product gap exhaustion.', + '', + ); + return; + } + + const unresolved = Array.isArray(report.buyer_gaps) ? report.buyer_gaps : []; + const unknown = Array.isArray(report.buyer_gap_unknown) + ? report.buyer_gap_unknown + : []; + if (unresolved.length === 0 && unknown.length === 0) { + lines.push( + 'No registered canonical buyer gaps remain open or unknown.', + '', + ); + return; + } + for (const gap of unresolved.slice(0, maxGaps)) { + lines.push( + `- **${sanitizeUntrustedText(gap.gap_id)}** — ${issueLink(gap.issue_number)} — open`, + ` - Capability links: ${capabilityList(gap.capability_ids) || 'none'}`, + ); + } + for (const gap of unknown.slice(0, maxGaps)) { + lines.push( + `- **${sanitizeUntrustedText(gap.gap_id)}** — ${issueLink(gap.issue_number)} — **state unknown**`, + ` - Capability links: ${capabilityList(gap.capability_ids) || 'none'}`, + ); + } + lines.push(''); +} + +/** + * Appends configured capability-evidence gaps to the Markdown report. + * These entries describe missing repository evidence for registered + * capabilities and are deliberately separate from canonical buyer-visible + * gaps, which can remain open even when capability maturity is at target. + */ +function renderCapabilityEvidenceGaps(lines, report, maxGaps) { + lines.push('## Capability evidence gaps', ''); + if (!Array.isArray(report.gaps) || report.gaps.length === 0) { + lines.push( + 'No capability evidence gaps remain at the configured target maturity levels.', + '', + ); + return; + } + for (const gap of report.gaps.slice(0, maxGaps)) { + lines.push( + `### ${sanitizeUntrustedText(gap.capability_id)} · score ${gap.priority_score}`, + '', + `- Outcome: ${sanitizeUntrustedText(gap.outcome)}`, + `- Maturity: \`${gap.observed_maturity}\` → \`${gap.target_maturity}\``, + `- Tracking: ${issueLink(gap.tracking_issue)}`, + `- Missing evidence: ${ + gap.missing_evidence + .map((path) => `\`${sanitizeUntrustedText(path)}\``) + .join(', ') || 'none recorded' + }`, + '', + ); + } +} + +/** + * Produces the credential-safe Markdown issue body for one readiness snapshot. + * The renderer reports configured capability evidence and canonical buyer-gap + * reconciliation as independent dimensions, then lists PR-drain evidence. The + * returned Markdown never promotes one dimension to proof that the other is + * complete. + */ export function renderCommercialReadinessIssue( report, snapshot, { marker, maxGaps = 15 }, ) { + const capabilityEvidenceGaps = Number.isSafeInteger( + report.summary?.capability_evidence_gaps, + ) + ? report.summary.capability_evidence_gaps + : report.summary.unresolved_gaps; const lines = [ marker, '# LifeOS commercial readiness', '', - '> Generated from repository evidence. Documentation claims do not satisfy implementation or test probes.', + '> Generated from repository evidence. Documentation claims do not satisfy implementation or test probes. Capability maturity and canonical buyer-gap state are independent evidence dimensions.', '', `- Commit: \`${report.commit_sha}\``, `- Evidence timestamp: \`${report.generated_at}\``, - `- Weighted maturity: **${report.summary.weighted_maturity_percent}%**`, + `- Configured weighted maturity: **${report.summary.weighted_maturity_percent}%**`, `- Capabilities at target: **${report.summary.at_target}/${report.summary.total_capabilities}**`, - `- Unresolved buyer gaps: **${report.summary.unresolved_gaps}**`, - '', - '## Highest-impact buyer gaps', - '', + `- Capability evidence gaps: **${capabilityEvidenceGaps}**`, ]; - if (report.gaps.length === 0) { + if (Number.isSafeInteger(report.summary?.unresolved_buyer_gaps)) { lines.push( - 'No evidence-backed capability gaps remain at the current target levels.', + `- Unresolved canonical buyer gaps: **${report.summary.unresolved_buyer_gaps}**`, + `- Unknown canonical buyer-gap states: **${report.summary.unknown_buyer_gap_states}**`, ); } else { - for (const gap of report.gaps.slice(0, maxGaps)) { - lines.push( - `### ${sanitizeUntrustedText(gap.capability_id)} · score ${gap.priority_score}`, - '', - `- Outcome: ${sanitizeUntrustedText(gap.outcome)}`, - `- Maturity: \`${gap.observed_maturity}\` → \`${gap.target_maturity}\``, - `- Tracking: ${issueLink(gap.tracking_issue)}`, - `- Missing evidence: ${ - gap.missing_evidence - .map((path) => `\`${sanitizeUntrustedText(path)}\``) - .join(', ') || 'none recorded' - }`, - '', - ); - } + lines.push('- Canonical buyer-gap evidence: **not evaluated**'); } + lines.push(''); + + renderCanonicalBuyerGaps(lines, report, maxGaps); + renderCapabilityEvidenceGaps(lines, report, maxGaps); lines.push('## Pull request drain', ''); const pulls = Array.isArray(snapshot.pull_requests) diff --git a/product/buyer-gaps.json b/product/buyer-gaps.json new file mode 100644 index 00000000..92a7aca6 --- /dev/null +++ b/product/buyer-gaps.json @@ -0,0 +1,25 @@ +{ + "schema": "life-os.commercial-buyer-gaps.v1", + "gaps": [ + { + "gap_id": "data.portability-completion", + "issue_number": 55, + "capability_ids": ["data.portability-rights"] + }, + { + "gap_id": "today.multi-device-sync", + "issue_number": 121, + "capability_ids": ["planning.durable-data", "today.action-loop"] + }, + { + "gap_id": "calendar.per-user-credentials", + "issue_number": 129, + "capability_ids": ["calendar.time-blocking"] + }, + { + "gap_id": "plugins.runtime-delivery", + "issue_number": 130, + "capability_ids": ["integrations.plugin-surface"] + } + ] +}