From 5a06debfef7b899e441b886dea2df95a70b1029e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Sun, 27 Sep 2026 18:48:48 -0400 Subject: [PATCH] Add gated delivery lifecycle --- README.md | 28 ++ docs/PORTABLE_AGENT_FLEET_SPEC.md | 12 + package.json | 6 +- ...grity-note-2026-09-27-portable-kernel.yaml | 7 + src/adapters/delivery.mjs | 25 ++ src/cli.mjs | 64 ++++ src/delivery/service.mjs | 317 ++++++++++++++++++ src/kernel/config.mjs | 15 + src/kernel/install.mjs | 15 + src/observability/snapshot.mjs | 7 +- src/self-host/service.mjs | 1 + test/fleet/cli-scenarios.test.mjs | 1 + test/fleet/delivery.test.mjs | 176 ++++++++++ 13 files changed, 671 insertions(+), 3 deletions(-) create mode 100644 src/adapters/delivery.mjs create mode 100644 src/delivery/service.mjs create mode 100644 test/fleet/delivery.test.mjs diff --git a/README.md b/README.md index d8fbefe..6b98545 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,9 @@ and the first Claude reference-fleet path: - a native integration queue with durable authorization and fast-forward main protection; - domain-owned convergence plans that defer during checks, measurements, pins, dirty work, and Git operations; - a local bare canonical Git option with honest one-disk/local-remote grading; +- a durable delivery state machine that keeps implemented, verified, + integrated, release-ready, released, deployed, and live-verified distinct, + with per-state authority and provider-capability gates; - mutation audit events tied to project and Fleet identity. - isolated candidate version stores with explicit compatibility declarations; - acceptance-gated atomic activation, crash reconciliation, and exact rollback. @@ -268,6 +271,31 @@ checks, backlog, worktrees, and policy remain operational. Detach works during that outage and restores the prior local canonical configuration; it does not delete a user-owned remote. +Delivery is separate from integration. A clean domain tip starts as +implemented; exact-commit checks gate verification, and a landed integration +record gates integration. Release, deployment, and live verification require +owner authority, explicit approval, and successful adapter receipts. Fresh +installs configure no delivery providers, so TORCH cannot publish or deploy by +accident: + +```bash +torch delivery create --area backend --label "API change" \ + --evidence "task:TASK-42" --json +torch delivery plan --delivery --to verified --by backend --json +torch delivery transition --delivery --to verified --by backend \ + --evidence "check:" --json +torch delivery configure --release-provider \ + --deployment-provider --dry-run --json +torch delivery configure --release-provider \ + --deployment-provider --yes --json +torch delivery transition --delivery --to released --by owner \ + --evidence "artifact:" --yes --json +``` + +Adapter names configure policy; a trusted installed adapter must also declare +and implement the requested capability. The stock CLI ships with no release or +deployment provider, so configuration alone cannot trigger an external action. + TORCH source checkouts can stage a self-host candidate without changing the active version. Candidate build runs the complete local acceptance, lint, and syntax gates before writing its receipt: diff --git a/docs/PORTABLE_AGENT_FLEET_SPEC.md b/docs/PORTABLE_AGENT_FLEET_SPEC.md index da2cdca..226b283 100644 --- a/docs/PORTABLE_AGENT_FLEET_SPEC.md +++ b/docs/PORTABLE_AGENT_FLEET_SPEC.md @@ -1217,6 +1217,18 @@ configuration MUST state who may perform each transition. Secrets are resolved at execution time from approved external stores and never copied into tracked Fleet state. +Implementation checkpoint (2026-09-27): TORCH now persists this complete state +machine in the local control plane. Creation requires a clean, exact source +worktree tip and evidence; verification requires the configured exact-commit +check receipts; integration requires a landed request for that commit; and +release, deployment, and live verification require separately configured +provider capabilities, state-specific authority, evidence, successful adapter +receipts, and explicit owner approval. Fresh installations configure both +provider slots as `none`, so no external release or deployment can occur by +default. The CLI and read-only Fleet observation surface are implemented and +scenario-tested with a virtual adapter; no live release or deployment was +performed. + ## 25. Security and trust boundaries TORCH MUST: diff --git a/package.json b/package.json index d60a9ef..04c5e0c 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "./console": "./src/console/server.mjs", "./design/brief": "./src/design/brief.mjs", "./design/architect": "./src/design/architect.mjs", + "./delivery": "./src/delivery/service.mjs", "./evolution": "./src/evolution/service.mjs", "./forge": "./src/forge/service.mjs", "./control-plane": "./src/control-plane/service.mjs", @@ -66,6 +67,7 @@ "src/convergence/", "src/control-plane/", "src/design/", + "src/delivery/", "src/evolution/", "src/forge/", "src/kernel/", @@ -90,8 +92,8 @@ "torch": "node bin/torch.mjs", "check": "node --check bin/torch.mjs && node --check src/cli.mjs && node --check src/mcp/server.mjs && node --check src/console/server.mjs && node --check site/app.js && node --check site/console.js", "site:serve": "node bin/torch.mjs console serve", - "format": "eslint src/adapters src/backlog src/canonical src/checks src/console src/convergence src/control-plane src/design src/evolution src/forge src/importers src/integration src/kernel src/mcp src/observability src/resources src/runtime src/schedules src/self-host src/telemetry src/cli.mjs bin/torch.mjs site/*.js test/fleet --fix", - "lint": "eslint src/adapters src/backlog src/canonical src/checks src/console src/convergence src/control-plane src/design src/evolution src/forge src/importers src/integration src/kernel src/mcp src/observability src/resources src/runtime src/schedules src/self-host src/telemetry src/cli.mjs bin/torch.mjs site/*.js test/fleet" + "format": "eslint src/adapters src/backlog src/canonical src/checks src/console src/convergence src/control-plane src/delivery src/design src/evolution src/forge src/importers src/integration src/kernel src/mcp src/observability src/resources src/runtime src/schedules src/self-host src/telemetry src/cli.mjs bin/torch.mjs site/*.js test/fleet --fix", + "lint": "eslint src/adapters src/backlog src/canonical src/checks src/console src/convergence src/control-plane src/delivery src/design src/evolution src/forge src/importers src/integration src/kernel src/mcp src/observability src/resources src/runtime src/schedules src/self-host src/telemetry src/cli.mjs bin/torch.mjs site/*.js test/fleet" }, "dependencies": { "@modelcontextprotocol/server": "^2.1.0", diff --git a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml index 9dc4d5a..43e2622 100644 --- a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml +++ b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml @@ -134,6 +134,10 @@ test_integrity_note: given: a converged domain commit with every required exact-SHA receipt when: the domain requests integration and the Session Manager authorizes and lands it then: main fast-forwards only while source, target, receipts, cleanliness, and authority remain valid + - id: SCN-delivery-lifecycle + given: a clean implemented domain commit, exact check receipt, landed integration, and a virtual provider adapter + when: TORCH advances it through verified, integrated, release-ready, released, deployed, and live-verified + then: states cannot be skipped, authority remains separate, high-impact transitions need explicit approval, adapters declare capabilities, receipts persist, and CLI plus observation expose the same state - id: SCN-safe-convergence given: a managed domain worktree behind canonical main when: a check, measurement, pin, dirty state, or Git operation could make convergence unsafe @@ -253,6 +257,7 @@ test_integrity_note: - immutable check receipt commit and result - resource holder, FIFO position, expiry, and cancellation state - canonical main SHA before and after authorized landing + - durable delivery state, authority blockers, adapter invocation order, and provider receipts - Git remote configuration and recoverability grade determinism_controls: - isolated temporary Git repositories @@ -295,6 +300,8 @@ test_integrity_note: - passing receipts detached from the commit actually measured - unfair or automatically stolen resource leases - integration based on branch labels, stale checks, or stale main + - integration authority being treated as release or deployment authority + - release, deployment, or live claims without explicit adapter receipts - a same-disk bare repository mislabeled as off-machine recovery - a merely passing command being mislabeled as complete candidate acceptance - candidate staging silently replacing stable or accepted code changing before activation diff --git a/src/adapters/delivery.mjs b/src/adapters/delivery.mjs new file mode 100644 index 0000000..f6c8e3a --- /dev/null +++ b/src/adapters/delivery.mjs @@ -0,0 +1,25 @@ +import { TorchError } from '../kernel/errors.mjs'; + +export const DELIVERY_OPERATIONS = Object.freeze(['release', 'deploy', 'verifyLive']); + +export function validateDeliveryAdapter(adapter) { + if (!adapter || typeof adapter.name !== 'string' || !adapter.capabilities) { + throw new TorchError('Delivery adapter is missing its name or capability declaration', { + code: 'INVALID_DELIVERY_ADAPTER', + }); + } + for (const operation of DELIVERY_OPERATIONS) { + if (!(operation in adapter.capabilities) || typeof adapter[operation] !== 'function') { + throw new TorchError(`Delivery adapter ${adapter.name} does not declare and implement ${operation}`, { + code: 'INVALID_DELIVERY_ADAPTER', details: { operation }, + }); + } + } + return adapter; +} + +export function unsupportedDeliveryCapability(adapterName, operation) { + throw new TorchError(`${adapterName} delivery adapter does not support ${operation}`, { + code: 'DELIVERY_CAPABILITY_UNSUPPORTED', details: { adapter: adapterName, operation }, + }); +} diff --git a/src/cli.mjs b/src/cli.mjs index 005b042..2ac4741 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -38,6 +38,9 @@ import { ConvergenceService } from './convergence/service.mjs'; import { attachForge, detachForge, forgeStatus, planForgeAttach, planForgeDetach, } from './forge/service.mjs'; +import { + configureDelivery, DeliveryService, planDeliveryConfiguration, +} from './delivery/service.mjs'; const HELP = `TORCH — portable agent fleet @@ -103,6 +106,12 @@ Usage: torch forge attach --remote [--provider ] --yes [--json] torch forge status [--json] torch forge detach [--dry-run] --yes [--json] + torch delivery list [--state ] [--json] + torch delivery get --delivery [--json] + torch delivery create --area [--commit ] --label --evidence [--json] + torch delivery plan --delivery --to --by [--json] + torch delivery transition --delivery --to --by --evidence [--yes] [--json] + torch delivery configure [--release-provider ] [--deployment-provider ] [--dry-run] --yes [--json] torch candidate plan --source [--version ] [--json] torch candidate build --source [--version ] --yes [--json] torch candidate status [--json] @@ -239,6 +248,7 @@ function runCandidateAcceptance(candidateRoot, spawn) { ['fleet-boundary-evolution', ['SCN-fleet-boundary-evolution']], ['cli-lifecycle-surface', ['SCN-cli-lifecycle-surface']], ['forge-migration', ['SCN-forge-migration']], + ['delivery-lifecycle', ['SCN-delivery-lifecycle']], ['review-install-roster', ['SCN-cli-domain-review']], ['branches-worktrees', ['SCN-worktree-bootstrap']], ['runtime-identities', ['SCN-mixed-runtime']], @@ -1078,6 +1088,60 @@ export async function runCli(argv = process.argv.slice(2), { } throw new TorchError(`Unknown forge operation: ${operation}`, { code: 'UNKNOWN_COMMAND' }); } + if (command === 'delivery') { + const operation = argv[1] ?? 'list'; + if (operation === 'configure') { + const input = { + repositoryRoot: repository.root, + releaseProvider: optionValue(argv, '--release-provider') ?? 'none', + deploymentProvider: optionValue(argv, '--deployment-provider') ?? 'none', + }; + if (argv.includes('--dry-run')) { + const plan = planDeliveryConfiguration(input); + print(plan, { json }); + return plan.canProceed ? 0 : 1; + } + if (!argv.includes('--yes')) { + throw new TorchError('Delivery adapter configuration changes tracked project policy. Review --dry-run, then use --yes.', { + code: 'APPROVAL_REQUIRED', + }); + } + print(configureDelivery(input), { json }); + return 0; + } + const control = openControlPlane({ repositoryRoot: repository.root, env }); + try { + const resources = new ResourceService({ repositoryRoot: repository.root, controlPlane: control }); + const checks = new CheckService({ + repositoryRoot: repository.root, controlPlane: control, resourceService: resources, + }); + const delivery = new DeliveryService({ + repositoryRoot: repository.root, controlPlane: control, checkService: checks, + }); + const deliveryId = optionValue(argv, '--delivery'); + if (operation === 'list') print({ deliveries: delivery.list({ state: optionValue(argv, '--state') }) }, { json }); + else if (operation === 'get') print(delivery.get(deliveryId), { json }); + else if (operation === 'create') print(delivery.create({ + sourceArea: optionValue(argv, '--area'), commit: optionValue(argv, '--commit') ?? 'HEAD', + label: optionValue(argv, '--label'), evidence: commaList(optionValue(argv, '--evidence')), + }), { json }); + else if (operation === 'plan') { + const plan = delivery.planTransition({ + deliveryId, targetState: optionValue(argv, '--to'), actor: optionValue(argv, '--by'), + }); + print(plan, { json }); + return plan.canProceed ? 0 : 1; + } else if (operation === 'transition') { + print(delivery.transition({ + deliveryId, targetState: optionValue(argv, '--to'), actor: optionValue(argv, '--by'), + evidence: commaList(optionValue(argv, '--evidence')), approved: argv.includes('--yes'), + }), { json }); + } else throw new TorchError(`Unknown delivery operation: ${operation}`, { code: 'UNKNOWN_COMMAND' }); + return 0; + } finally { + control.close(); + } + } if (command === 'uninstall') { const result = uninstallProject({ repository, diff --git a/src/delivery/service.mjs b/src/delivery/service.mjs new file mode 100644 index 0000000..f8f9751 --- /dev/null +++ b/src/delivery/service.mjs @@ -0,0 +1,317 @@ +import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { renameSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { TorchError } from '../kernel/errors.mjs'; +import { loadProjectConfig } from '../kernel/config.mjs'; +import { readInstallManifest, writeInstallManifest } from '../kernel/install.mjs'; +import { fileHash } from '../kernel/files.mjs'; +import { validateDeliveryAdapter } from '../adapters/delivery.mjs'; + +export const DELIVERY_STATES = Object.freeze([ + 'implemented', 'verified', 'integrated', 'release-ready', 'released', 'deployed', 'live-verified', +]); + +const HIGH_IMPACT_STATES = new Set(['released', 'deployed', 'live-verified']); + +function text(value, name) { + if (typeof value !== 'string' || !value.trim()) { + throw new TorchError(`${name} must be a non-empty string`, { + code: 'DELIVERY_INPUT_INVALID', details: { field: name }, + }); + } + return value.trim(); +} + +function textList(value, name) { + if (!Array.isArray(value) || value.length === 0 + || value.some((entry) => typeof entry !== 'string' || !entry.trim())) { + throw new TorchError(`${name} must be a non-empty array of strings`, { + code: 'DELIVERY_INPUT_INVALID', details: { field: name }, + }); + } + return [...new Set(value.map((entry) => entry.trim()))]; +} + +function git(root, args) { + const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' }); + if (result.status !== 0) throw new TorchError('Delivery Git inspection failed', { + code: 'GIT_COMMAND_FAILED', details: result.stderr?.trim() || null, + }); + return result.stdout.trim(); +} + +function initialize(database) { + database.exec(` + CREATE TABLE IF NOT EXISTS deliveries ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + label TEXT NOT NULL, + source_area TEXT NOT NULL, + commit_sha TEXT NOT NULL, + state TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + evidence_json TEXT NOT NULL, + adapter_receipts_json TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS delivery_events ( + id TEXT PRIMARY KEY, + delivery_id TEXT NOT NULL REFERENCES deliveries(id) ON DELETE CASCADE, + from_state TEXT, + to_state TEXT NOT NULL, + actor TEXT NOT NULL, + created_at TEXT NOT NULL, + evidence_json TEXT NOT NULL, + adapter_receipt_json TEXT + ); + CREATE INDEX IF NOT EXISTS deliveries_state_updated ON deliveries(state, updated_at, id); + `); +} + +function hasTable(database, name) { + return Boolean(database.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)); +} + +function rowToDelivery(row) { + if (!row) return null; + return { + id: row.id, projectId: row.project_id, label: row.label, sourceArea: row.source_area, + commit: row.commit_sha, state: row.state, createdAt: row.created_at, updatedAt: row.updated_at, + evidence: JSON.parse(row.evidence_json), adapterReceipts: JSON.parse(row.adapter_receipts_json), + }; +} + +function adapterRequirement(config, targetState) { + if (targetState === 'released') { + return { slot: 'release', operation: 'release', provider: config.delivery.adapters.release.provider }; + } + if (targetState === 'deployed') { + return { slot: 'deployment', operation: 'deploy', provider: config.delivery.adapters.deployment.provider }; + } + if (targetState === 'live-verified') { + return { slot: 'deployment', operation: 'verifyLive', provider: config.delivery.adapters.deployment.provider }; + } + return null; +} + +function configurationPaths(repositoryRoot) { + return { config: join(repositoryRoot, '.torch', 'torch.yaml') }; +} + +function ownedConfiguration(repositoryRoot, manifest) { + const record = manifest.created.find((entry) => entry.path === '.torch/torch.yaml'); + return Boolean(record && fileHash(configurationPaths(repositoryRoot).config) === record.sha256); +} + +export function planDeliveryConfiguration({ + repositoryRoot, releaseProvider = 'none', deploymentProvider = 'none', +} = {}) { + const release = text(releaseProvider, 'releaseProvider'); + const deployment = text(deploymentProvider, 'deploymentProvider'); + const config = loadProjectConfig(repositoryRoot); + const manifest = readInstallManifest(repositoryRoot); + const blockers = []; + if (!ownedConfiguration(repositoryRoot, manifest)) blockers.push({ code: 'DELIVERY_CONFIGURATION_MODIFIED' }); + return { + action: 'configure-delivery-adapters', + current: { + release: config.delivery.adapters.release.provider, + deployment: config.delivery.adapters.deployment.provider, + }, + requested: { release, deployment }, + blockers, canProceed: blockers.length === 0, mutationPerformed: false, + }; +} + +export function configureDelivery(input = {}) { + const plan = planDeliveryConfiguration(input); + if (!plan.canProceed) throw new TorchError('Delivery adapter configuration is blocked', { + code: 'DELIVERY_CONFIGURATION_BLOCKED', details: plan.blockers, + }); + const config = loadProjectConfig(input.repositoryRoot); + const manifest = readInstallManifest(input.repositoryRoot); + config.delivery.adapters.release.provider = plan.requested.release; + config.delivery.adapters.deployment.provider = plan.requested.deployment; + const path = configurationPaths(input.repositoryRoot).config; + const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`; + writeFileSync(temporary, `${JSON.stringify(config, null, 2)}\n`, { + encoding: 'utf8', mode: 0o600, flag: 'wx', + }); + renameSync(temporary, path); + const record = manifest.created.find((entry) => entry.path === '.torch/torch.yaml'); + record.sha256 = fileHash(path); + writeInstallManifest(input.repositoryRoot, manifest); + return { ...plan, configuredAt: new Date().toISOString(), mutationPerformed: true }; +} + +export class DeliveryService { + constructor({ + repositoryRoot, controlPlane, checkService = null, adapters = new Map(), + clock = () => new Date(), idFactory = randomUUID, + } = {}) { + if (!controlPlane) throw new TorchError('Delivery lifecycle requires the TORCH control plane', { code: 'CONTROL_PLANE_REQUIRED' }); + this.repositoryRoot = repositoryRoot; + this.controlPlane = controlPlane; + this.checkService = checkService; + this.adapters = adapters instanceof Map ? adapters : new Map(Object.entries(adapters)); + this.clock = clock; + this.idFactory = idFactory; + this.database = controlPlane.database; + this.config = loadProjectConfig(repositoryRoot); + this.manifest = readInstallManifest(repositoryRoot); + this.worktrees = new Map((this.manifest.external ?? []) + .filter((entry) => entry.type === 'worktree').map((entry) => [entry.area, entry])); + initialize(this.database); + } + + list({ state } = {}) { + if (state && !DELIVERY_STATES.includes(state)) { + throw new TorchError(`Unknown delivery state: ${state}`, { code: 'DELIVERY_STATE_INVALID' }); + } + const rows = state + ? this.database.prepare('SELECT * FROM deliveries WHERE state = ? ORDER BY updated_at, id').all(state) + : this.database.prepare('SELECT * FROM deliveries ORDER BY updated_at, id').all(); + return rows.map(rowToDelivery); + } + + get(deliveryId) { + const id = text(deliveryId, 'deliveryId'); + const delivery = rowToDelivery(this.database.prepare('SELECT * FROM deliveries WHERE id = ?').get(id)); + if (!delivery) throw new TorchError(`Unknown delivery: ${id}`, { code: 'DELIVERY_NOT_FOUND' }); + return delivery; + } + + create({ sourceArea, commit = 'HEAD', label, evidence } = {}) { + const area = this.controlPlane.assertIdentity(sourceArea); + if (area === 'session-manager') { + throw new TorchError('A delivery source must be a development domain', { code: 'DELIVERY_SOURCE_INVALID' }); + } + const worktree = this.worktrees.get(area); + if (!worktree) throw new TorchError(`No managed worktree for ${area}`, { code: 'WORKTREE_MISSING' }); + const resolvedCommit = git(worktree.path, ['rev-parse', text(commit, 'commit')]); + const head = git(worktree.path, ['rev-parse', 'HEAD']); + const dirty = git(worktree.path, ['status', '--porcelain']).split('\n').filter(Boolean); + if (resolvedCommit !== head || dirty.length) { + throw new TorchError('Delivery implementation must name the clean source worktree HEAD', { + code: 'DELIVERY_SOURCE_UNSAFE', details: { resolvedCommit, head, dirty }, + }); + } + const now = this.clock().toISOString(); + const record = { + id: this.idFactory(), projectId: this.controlPlane.projectId, label: text(label, 'label'), + sourceArea: area, commit: resolvedCommit, state: 'implemented', createdAt: now, updatedAt: now, + evidence: textList(evidence, 'evidence'), adapterReceipts: [], + }; + this.database.prepare(`INSERT INTO deliveries ( + id, project_id, label, source_area, commit_sha, state, created_at, updated_at, + evidence_json, adapter_receipts_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run(record.id, record.projectId, record.label, record.sourceArea, record.commit, + record.state, now, now, JSON.stringify(record.evidence), '[]'); + this.database.prepare(`INSERT INTO delivery_events ( + id, delivery_id, from_state, to_state, actor, created_at, evidence_json, adapter_receipt_json + ) VALUES (?, ?, NULL, 'implemented', ?, ?, ?, NULL)`) + .run(this.idFactory(), record.id, area, now, JSON.stringify(record.evidence)); + this.controlPlane.audit({ + actorId: area, operation: 'delivery.create', entityType: 'delivery', entityId: record.id, + details: { commit: record.commit, state: record.state }, + }); + return record; + } + + planTransition({ deliveryId, targetState, actor } = {}) { + const delivery = this.get(deliveryId); + const target = text(targetState, 'targetState'); + const currentIndex = DELIVERY_STATES.indexOf(delivery.state); + if (DELIVERY_STATES[currentIndex + 1] !== target) { + throw new TorchError(`Delivery must advance from ${delivery.state} to ${DELIVERY_STATES[currentIndex + 1] ?? ''}`, { + code: 'DELIVERY_TRANSITION_INVALID', details: { current: delivery.state, target }, + }); + } + const principal = text(actor, 'actor'); + if (principal !== 'owner') this.controlPlane.assertIdentity(principal); + const authorityKey = target.replaceAll('-', '_'); + const configuredAuthority = this.config.delivery.authority[authorityKey] ?? []; + const authorizedActors = configuredAuthority.map((entry) => entry === '$source' ? delivery.sourceArea : entry); + const blockers = []; + if (!authorizedActors.includes(principal)) blockers.push({ code: 'DELIVERY_AUTHORITY_REQUIRED', actor: principal, allowed: authorizedActors }); + if (target === 'verified') { + const requiredChecks = this.config.integration.required_checks; + if (requiredChecks.length && !this.checkService?.exactPasses({ commit: delivery.commit, requiredChecks })) { + blockers.push({ code: 'DELIVERY_CHECKS_REQUIRED', commit: delivery.commit, checks: requiredChecks }); + } + } + if (target === 'integrated') { + const landed = hasTable(this.database, 'integration_requests') + ? this.database.prepare(`SELECT id FROM integration_requests + WHERE source_commit = ? AND state = 'landed' LIMIT 1`).get(delivery.commit) + : null; + if (!landed) blockers.push({ code: 'DELIVERY_INTEGRATION_REQUIRED', commit: delivery.commit }); + } + const requirement = adapterRequirement(this.config, target); + if (requirement) { + if (requirement.provider === 'none') blockers.push({ + code: 'DELIVERY_ADAPTER_NOT_CONFIGURED', slot: requirement.slot, operation: requirement.operation, + }); + else { + const adapter = this.adapters.get(requirement.provider) ?? null; + if (!adapter) blockers.push({ code: 'DELIVERY_ADAPTER_MISSING', provider: requirement.provider }); + else { + validateDeliveryAdapter(adapter); + if (adapter.capabilities[requirement.operation] !== true) blockers.push({ + code: 'DELIVERY_CAPABILITY_UNSUPPORTED', provider: requirement.provider, operation: requirement.operation, + }); + } + } + } + return { + action: 'delivery-transition', deliveryId: delivery.id, from: delivery.state, to: target, + actor: principal, authority: authorizedActors, adapter: requirement ? { + slot: requirement.slot, provider: requirement.provider, operation: requirement.operation, + } : null, + requiresApproval: HIGH_IMPACT_STATES.has(target), blockers, + canProceed: blockers.length === 0, mutationPerformed: false, + }; + } + + transition({ deliveryId, targetState, actor, evidence, approved = false } = {}) { + const plan = this.planTransition({ deliveryId, targetState, actor }); + if (!plan.canProceed) throw new TorchError('Delivery transition is blocked', { + code: 'DELIVERY_TRANSITION_BLOCKED', details: plan.blockers, + }); + if (plan.requiresApproval && !approved) throw new TorchError('High-impact delivery transition requires explicit owner approval', { + code: 'APPROVAL_REQUIRED', details: { state: plan.to }, + }); + const evidenceList = textList(evidence, 'evidence'); + const delivery = this.get(deliveryId); + let receipt = null; + if (plan.adapter) { + const adapter = this.adapters.get(plan.adapter.provider); + receipt = adapter[plan.adapter.operation]({ delivery, evidence: evidenceList }); + if (!receipt || receipt.status !== 'succeeded') throw new TorchError('Delivery adapter did not return a successful receipt', { + code: 'DELIVERY_ADAPTER_FAILED', details: { adapter: plan.adapter, receipt }, + }); + } + const now = this.clock().toISOString(); + const receipts = [...delivery.adapterReceipts, ...(receipt ? [{ ...receipt, operation: plan.adapter.operation }] : [])]; + const allEvidence = [...new Set([...delivery.evidence, ...evidenceList])]; + const update = this.database.prepare(`UPDATE deliveries SET state = ?, updated_at = ?, evidence_json = ?, + adapter_receipts_json = ? WHERE id = ? AND state = ?`) + .run(plan.to, now, JSON.stringify(allEvidence), JSON.stringify(receipts), delivery.id, delivery.state); + if (update.changes !== 1) throw new TorchError('Delivery state changed concurrently', { + code: 'DELIVERY_STATE_CONFLICT', details: { deliveryId: delivery.id, expected: delivery.state }, + }); + this.database.prepare(`INSERT INTO delivery_events ( + id, delivery_id, from_state, to_state, actor, created_at, evidence_json, adapter_receipt_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`) + .run(this.idFactory(), delivery.id, delivery.state, plan.to, plan.actor, now, + JSON.stringify(evidenceList), receipt ? JSON.stringify(receipt) : null); + this.controlPlane.audit({ + actorId: plan.actor === 'owner' ? 'session-manager' : plan.actor, + operation: `delivery.${plan.to}`, entityType: 'delivery', entityId: delivery.id, + details: { commit: delivery.commit, approved, adapter: plan.adapter }, + }); + return this.get(delivery.id); + } +} diff --git a/src/kernel/config.mjs b/src/kernel/config.mjs index 292feee..8fb2369 100644 --- a/src/kernel/config.mjs +++ b/src/kernel/config.mjs @@ -98,6 +98,21 @@ export const projectConfigSchema = z.object({ required_checks: textList, landing_authority: textList.min(1), }).strict(), + delivery: z.object({ + authority: z.object({ + implemented: textList.min(1), + verified: textList.min(1), + integrated: textList.min(1), + release_ready: textList.min(1), + released: textList.min(1), + deployed: textList.min(1), + live_verified: textList.min(1), + }).strict(), + adapters: z.object({ + release: z.object({ provider: text }).passthrough(), + deployment: z.object({ provider: text }).passthrough(), + }).strict(), + }).strict(), session_manager: z.object({ id: z.literal('session-manager'), runtime: text, diff --git a/src/kernel/install.mjs b/src/kernel/install.mjs index 6d9b6ee..af6697a 100644 --- a/src/kernel/install.mjs +++ b/src/kernel/install.mjs @@ -79,6 +79,21 @@ function initialFiles({ repository, projectId, createdAt, proposal, runtimes }) required_checks: (proposal.checks ?? []).map((check) => check.id), landing_authority: ['session-manager'], }, + delivery: { + authority: { + implemented: ['$source'], + verified: ['$source', 'session-manager'], + integrated: ['session-manager'], + release_ready: ['session-manager'], + released: ['owner'], + deployed: ['owner'], + live_verified: ['owner'], + }, + adapters: { + release: { provider: 'none' }, + deployment: { provider: 'none' }, + }, + }, session_manager: { id: 'session-manager', runtime: proposal.session_manager?.runtime ?? 'claude', start_last: true, branch: proposal.session_manager?.branch ?? null, diff --git a/src/observability/snapshot.mjs b/src/observability/snapshot.mjs index bd0447d..6f90440 100644 --- a/src/observability/snapshot.mjs +++ b/src/observability/snapshot.mjs @@ -79,6 +79,7 @@ export function observeProject({ repositoryRoot, env = process.env, now = () => checks: { definitions: config.checks ?? [], receipts: [] }, resources: { definitions: config.resources ?? [], holders: [], waiters: [] }, integration: [], + deliveries: [], fleetChanges: [], schedules: config.schedules ?? [], audit: [], @@ -122,7 +123,7 @@ export function observeProject({ repositoryRoot, env = process.env, now = () => } } result.checks.receipts = rows(database, 'check_receipts', ` - SELECT id, check_id AS checkId, area_id AS areaId, commit_sha AS commit, result, finished_at AS finishedAt + SELECT id, check_id AS checkId, area_id AS areaId, commit_sha AS "commit", result, finished_at AS finishedAt FROM check_receipts ORDER BY finished_at DESC LIMIT 40 `); result.resources.holders = rows(database, 'resource_leases', ` @@ -137,6 +138,10 @@ export function observeProject({ repositoryRoot, env = process.env, now = () => SELECT id, source_area AS sourceArea, source_commit AS sourceCommit, target_branch AS targetBranch, state, reason, updated_at AS updatedAt FROM integration_requests ORDER BY updated_at DESC LIMIT 40 `); + result.deliveries = rows(database, 'deliveries', ` + SELECT id, label, source_area AS sourceArea, commit_sha AS "commit", state, updated_at AS updatedAt + FROM deliveries ORDER BY updated_at DESC, id LIMIT 40 + `); result.fleetChanges = rows(database, 'fleet_changes', ` SELECT id, change_type AS changeType, state, proposer, COALESCE(json_extract(domain_json, '$.id'), json_extract(proposal_json, '$.resultDomains[0].id')) AS domainId, diff --git a/src/self-host/service.mjs b/src/self-host/service.mjs index 29515a3..3552a6c 100644 --- a/src/self-host/service.mjs +++ b/src/self-host/service.mjs @@ -16,6 +16,7 @@ export const CANDIDATE_ACCEPTANCE_SCENARIOS = Object.freeze([ 'fleet-boundary-evolution', 'cli-lifecycle-surface', 'forge-migration', + 'delivery-lifecycle', 'review-install-roster', 'branches-worktrees', 'runtime-identities', diff --git a/test/fleet/cli-scenarios.test.mjs b/test/fleet/cli-scenarios.test.mjs index 203cd3e..8784af6 100644 --- a/test/fleet/cli-scenarios.test.mjs +++ b/test/fleet/cli-scenarios.test.mjs @@ -39,6 +39,7 @@ function releaseCandidate(root, version) { 'SCN-fleet-boundary-evolution', 'SCN-cli-lifecycle-surface', 'SCN-forge-migration', + 'SCN-delivery-lifecycle', 'SCN-durable-message', 'SCN-ownership-handoff', 'SCN-worktree-purge-safety', 'SCN-backlog-lifecycle', 'SCN-native-integration', 'SCN-resource-fifo', 'SCN-fleet-fresh-resume', 'SCN-install-doctor-purge', diff --git a/test/fleet/delivery.test.mjs b/test/fleet/delivery.test.mjs new file mode 100644 index 0000000..effc53f --- /dev/null +++ b/test/fleet/delivery.test.mjs @@ -0,0 +1,176 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import test from 'node:test'; +import { CheckService } from '../../src/checks/service.mjs'; +import { openControlPlane } from '../../src/control-plane/service.mjs'; +import { + configureDelivery, DeliveryService, planDeliveryConfiguration, +} from '../../src/delivery/service.mjs'; +import { analyzeRepository } from '../../src/kernel/analyze.mjs'; +import { proposeDomains } from '../../src/kernel/domains.mjs'; +import { inspectRepository } from '../../src/kernel/git.mjs'; +import { installProject } from '../../src/kernel/install.mjs'; +import { createWorktrees } from '../../src/kernel/worktrees.mjs'; +import { IntegrationService } from '../../src/integration/service.mjs'; +import { observeProject } from '../../src/observability/snapshot.mjs'; + +const CLI = new URL('../../bin/torch.mjs', import.meta.url).pathname; + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'torch-delivery-')); + execFileSync('git', ['init', '-b', 'main', root]); + execFileSync('git', ['-C', root, 'config', 'user.email', 'torch-test@example.invalid']); + execFileSync('git', ['-C', root, 'config', 'user.name', 'TORCH Test']); + writeFileSync(join(root, 'package.json'), `${JSON.stringify({ + name: 'delivery-fixture', scripts: { 'verify:pass': 'node -e "process.exit(0)"' }, + }, null, 2)}\n`); + writeFileSync(join(root, 'app.js'), 'export const value = 1;\n'); + execFileSync('git', ['-C', root, 'add', '.']); + execFileSync('git', ['-C', root, 'commit', '-m', 'fixture']); + let repository = inspectRepository(root); + const proposal = proposeDomains({ repository, analysis: analyzeRepository(repository) }); + proposal.checks = proposal.checks.filter((check) => check.id === 'verify-pass'); + proposal.review = { + status: 'approved', reviewedAt: '2026-09-27T00:00:00Z', reviewedBy: 'fixture-owner', notes: [], + }; + const env = { ...process.env, XDG_DATA_HOME: join(tmpdir(), `${basename(root)}-state`) }; + installProject({ repository, proposal, env, projectId: 'delivery-fixture' }); + execFileSync('git', ['-C', root, 'add', '.torch']); + execFileSync('git', ['-C', root, 'commit', '-m', 'install torch']); + repository = inspectRepository(root); + createWorktrees({ + repository, parentOverride: join(tmpdir(), `${basename(root)}-worktrees`), + }); + execFileSync('git', ['-C', root, 'add', '.torch/install-manifest.json']); + execFileSync('git', ['-C', root, 'commit', '-m', 'record managed worktrees']); + const manifest = JSON.parse(readFileSync(join(root, '.torch', 'install-manifest.json'), 'utf8')); + const worker = proposal.domains[0].id; + const worktree = manifest.external.find((entry) => entry.type === 'worktree' && entry.area === worker); + execFileSync('git', ['-C', worktree.path, 'merge', 'main']); + writeFileSync(join(worktree.path, 'app.js'), 'export const value = 2;\n'); + execFileSync('git', ['-C', worktree.path, 'add', 'app.js']); + execFileSync('git', ['-C', worktree.path, 'commit', '-m', 'delivery change']); + return { root, env, worker, worktree }; +} + +test('SCN-delivery-lifecycle: delivery authority, evidence, integration, and adapters are distinct gates', () => { + const context = fixture(); + const control = openControlPlane({ repositoryRoot: context.root, env: context.env }); + let serial = 0; + const idFactory = () => `delivery-id-${++serial}`; + const checks = new CheckService({ repositoryRoot: context.root, controlPlane: control, idFactory }); + let service = new DeliveryService({ + repositoryRoot: context.root, controlPlane: control, checkService: checks, idFactory, + }); + let delivery = service.create({ + sourceArea: context.worker, label: 'Portable release boundary', evidence: ['implementation commit'], + }); + assert.equal(delivery.state, 'implemented'); + assert.throws( + () => service.planTransition({ deliveryId: delivery.id, targetState: 'integrated', actor: 'session-manager' }), + (error) => error.code === 'DELIVERY_TRANSITION_INVALID', + ); + assert.equal(service.planTransition({ + deliveryId: delivery.id, targetState: 'verified', actor: context.worker, + }).blockers.some((blocker) => blocker.code === 'DELIVERY_CHECKS_REQUIRED'), true); + + const receipt = checks.run({ checkId: 'verify-pass', areaId: context.worker }); + delivery = service.transition({ + deliveryId: delivery.id, targetState: 'verified', actor: context.worker, + evidence: [`check:${receipt.id}`], + }); + assert.equal(delivery.state, 'verified'); + assert.equal(service.planTransition({ + deliveryId: delivery.id, targetState: 'integrated', actor: 'session-manager', + }).blockers.some((blocker) => blocker.code === 'DELIVERY_INTEGRATION_REQUIRED'), true); + + const integration = new IntegrationService({ + repositoryRoot: context.root, controlPlane: control, checkService: checks, idFactory, + }); + const request = integration.request({ areaId: context.worker, commit: receipt.commit }); + integration.authorize({ requestId: request.id, actorId: 'session-manager' }); + const landed = integration.land({ requestId: request.id, actorId: 'session-manager' }); + delivery = service.transition({ + deliveryId: delivery.id, targetState: 'integrated', actor: 'session-manager', + evidence: [`integration:${landed.id}`], + }); + delivery = service.transition({ + deliveryId: delivery.id, targetState: 'release-ready', actor: 'session-manager', + evidence: ['release checklist complete'], + }); + assert.equal(delivery.state, 'release-ready'); + assert.equal(service.planTransition({ + deliveryId: delivery.id, targetState: 'released', actor: 'owner', + }).blockers[0].code, 'DELIVERY_ADAPTER_NOT_CONFIGURED'); + + const configurationPlan = planDeliveryConfiguration({ + repositoryRoot: context.root, releaseProvider: 'fixture', deploymentProvider: 'fixture', + }); + assert.equal(configurationPlan.canProceed, true); + assert.equal(configurationPlan.mutationPerformed, false); + configureDelivery({ + repositoryRoot: context.root, releaseProvider: 'fixture', deploymentProvider: 'fixture', + }); + const invocations = []; + const adapter = { + name: 'fixture', capabilities: { release: true, deploy: true, verifyLive: true }, + release: ({ delivery: item }) => { + invocations.push('release'); return { status: 'succeeded', reference: `release:${item.commit}` }; + }, + deploy: ({ delivery: item }) => { + invocations.push('deploy'); return { status: 'succeeded', reference: `deploy:${item.commit}` }; + }, + verifyLive: ({ delivery: item }) => { + invocations.push('verifyLive'); return { status: 'succeeded', reference: `live:${item.commit}` }; + }, + }; + service = new DeliveryService({ + repositoryRoot: context.root, controlPlane: control, checkService: checks, + adapters: new Map([['fixture', adapter]]), idFactory, + }); + assert.equal(service.planTransition({ + deliveryId: delivery.id, targetState: 'released', actor: 'session-manager', + }).blockers[0].code, 'DELIVERY_AUTHORITY_REQUIRED'); + assert.throws( + () => service.transition({ + deliveryId: delivery.id, targetState: 'released', actor: 'owner', evidence: ['release candidate'], + }), + (error) => error.code === 'APPROVAL_REQUIRED', + ); + for (const [state, evidence] of [ + ['released', 'published artifact'], ['deployed', 'deployment receipt'], ['live-verified', 'live probe'], + ]) { + delivery = service.transition({ + deliveryId: delivery.id, targetState: state, actor: 'owner', evidence: [evidence], approved: true, + }); + } + assert.equal(delivery.state, 'live-verified'); + assert.deepEqual(invocations, ['release', 'deploy', 'verifyLive']); + assert.equal(delivery.adapterReceipts.length, 3); + assert.equal(observeProject({ repositoryRoot: context.root, env: context.env }).deliveries[0].state, 'live-verified'); + control.close(); + + const reopened = openControlPlane({ repositoryRoot: context.root, env: context.env }); + const durable = new DeliveryService({ + repositoryRoot: context.root, controlPlane: reopened, adapters: new Map([['fixture', adapter]]), + }).get(delivery.id); + assert.equal(durable.state, 'live-verified'); + assert.equal(durable.adapterReceipts.length, 3); + reopened.close(); + + const cliCreate = spawnSync(process.execPath, [ + CLI, 'delivery', 'create', '--area', context.worker, '--label', 'CLI delivery', + '--evidence', 'clean source tip', '--json', + ], { cwd: context.root, env: context.env, encoding: 'utf8' }); + assert.equal(cliCreate.status, 0, cliCreate.stderr || cliCreate.stdout); + const cliDelivery = JSON.parse(cliCreate.stdout); + assert.equal(cliDelivery.state, 'implemented'); + const cliList = spawnSync(process.execPath, [CLI, 'delivery', 'list', '--json'], { + cwd: context.root, env: context.env, encoding: 'utf8', + }); + assert.equal(cliList.status, 0, cliList.stderr || cliList.stdout); + assert.equal(JSON.parse(cliList.stdout).deliveries.some((item) => item.id === cliDelivery.id), true); +});