diff --git a/README.md b/README.md index 8342ff5..214c1bf 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ and the first Claude reference-fleet path: - spec-fingerprinted, evidence-linked fleet design with stale-spec refusal; - evidence-linked architecture graphs and domain-collision proposals; - owner-reviewed organizational proposals required before installation; +- strict versioned project-configuration validation with explicit migration blockers; - explicit install approval; - tracked `.torch/` project configuration; - XDG-local runtime state; diff --git a/package.json b/package.json index 0e719d6..77c9427 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "./evolution": "./src/evolution/service.mjs", "./control-plane": "./src/control-plane/service.mjs", "./kernel/analyze": "./src/kernel/analyze.mjs", + "./kernel/config": "./src/kernel/config.mjs", "./kernel/git": "./src/kernel/git.mjs", "./kernel/install": "./src/kernel/install.mjs", "./kernel/specifications": "./src/kernel/specifications.mjs", 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 d3f8f1f..b495bed 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 @@ -22,6 +22,10 @@ test_integrity_note: given: a generated organizational proposal that is pending or stale when: TORCH is asked to install it then: installation refuses before creating tracked or local state + - id: SCN-config-schema + given: an installed Fleet configuration and malformed or future configuration variants + when: TORCH validates or plans migration of the configuration + then: the current schema passes, safety invariants and unknown fields fail clearly, and unsupported versions receive an explicit migration blocker - id: SCN-cli-domain-review given: a Git repository analyzed through the public CLI when: the owner generates, reviews, approves, and installs its domain proposal diff --git a/src/canonical/local.mjs b/src/canonical/local.mjs index ec8573a..2efe304 100644 --- a/src/canonical/local.mjs +++ b/src/canonical/local.mjs @@ -4,6 +4,7 @@ import { dirname, join, resolve, sep } from 'node:path'; import { TorchError } from '../kernel/errors.mjs'; import { fileHash } from '../kernel/files.mjs'; import { readInstallManifest, writeInstallManifest } from '../kernel/install.mjs'; +import { loadProjectConfig } from '../kernel/config.mjs'; const REMOTE_NAME = 'torch-canonical'; @@ -33,7 +34,7 @@ function safeCanonicalPath(stateRoot) { function writeConfig(repositoryRoot, manifest, path) { const configPath = join(repositoryRoot, '.torch', 'torch.yaml'); - const config = JSON.parse(readFileSync(configPath, 'utf8')); + const config = loadProjectConfig(repositoryRoot); config.repository = { canonical: { type: 'local', remote: REMOTE_NAME, path } }; const temporary = `${configPath}.tmp-${process.pid}`; writeFileSync(temporary, `${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); @@ -56,7 +57,7 @@ export function planLocalCanonical({ repositoryRoot } = {}) { return { action: 'create-local-canonical', projectId: manifest.projectId, stateRoot, path, remote: REMOTE_NAME, - mainBranch: JSON.parse(readFileSync(join(repositoryRoot, '.torch', 'torch.yaml'), 'utf8')).project.main_branch, + mainBranch: loadProjectConfig(repositoryRoot).project.main_branch, blockers, canProceed: blockers.length === 0, mutationPerformed: false, }; } diff --git a/src/control-plane/service.mjs b/src/control-plane/service.mjs index 9c0afe6..7e58329 100644 --- a/src/control-plane/service.mjs +++ b/src/control-plane/service.mjs @@ -5,6 +5,7 @@ import { DatabaseSync } from 'node:sqlite'; import { TorchError } from '../kernel/errors.mjs'; import { readInstallManifest } from '../kernel/install.mjs'; import { projectStatePath } from '../kernel/paths.mjs'; +import { loadProjectConfig } from '../kernel/config.mjs'; export const PRESENCE_STATES = Object.freeze([ 'starting', 'working', 'waiting', 'idle', 'stale', 'stopping', 'offline', @@ -188,7 +189,7 @@ export class ControlPlane { this.clock = clock; this.idFactory = idFactory; this.manifest = readInstallManifest(this.repositoryRoot); - this.config = readJson(join(this.repositoryRoot, '.torch', 'torch.yaml'), 'INVALID_TORCH_CONFIG'); + this.config = loadProjectConfig(this.repositoryRoot); this.roster = readJson(join(this.repositoryRoot, '.torch', 'roster.yaml'), 'INVALID_TORCH_ROSTER'); if (this.config.project?.id !== this.manifest.projectId) { throw new TorchError('Tracked configuration and installation manifest disagree on project identity', { diff --git a/src/evolution/service.mjs b/src/evolution/service.mjs index 1b73b8c..820af07 100644 --- a/src/evolution/service.mjs +++ b/src/evolution/service.mjs @@ -6,6 +6,7 @@ import { dirname, isAbsolute, join, resolve } from 'node:path'; import { TorchError } from '../kernel/errors.mjs'; import { readInstallManifest } from '../kernel/install.mjs'; import { ensureTaskIgnored, removeTaskIgnore } from '../kernel/worktrees.mjs'; +import { loadProjectConfig } from '../kernel/config.mjs'; const CHANGE_STATES = Object.freeze(['proposed', 'approved', 'provisioning', 'active', 'rejected']); @@ -212,7 +213,7 @@ export class FleetEvolutionService { code: 'FLEET_OWNERSHIP_COLLISION', details: collisions, }); } - const config = readJson(join(this.repositoryRoot, '.torch', 'torch.yaml'), 'CONFIG_INVALID'); + const config = loadProjectConfig(this.repositoryRoot); if (!config.runtimes?.[domain.runtime]) { throw new TorchError(`Runtime is not configured for the Fleet: ${domain.runtime}`, { code: 'FLEET_RUNTIME_NOT_CONFIGURED', details: { runtime: domain.runtime }, @@ -277,7 +278,7 @@ export class FleetEvolutionService { planActivation(changeId) { const change = this.get(changeId); - const config = readJson(join(this.repositoryRoot, '.torch', 'torch.yaml'), 'CONFIG_INVALID'); + const config = loadProjectConfig(this.repositoryRoot); const roster = readJson(join(this.repositoryRoot, '.torch', 'roster.yaml'), 'ROSTER_INVALID'); const currentHead = git(this.repositoryRoot, ['rev-parse', 'HEAD']); const dirtyEntries = git(this.repositoryRoot, ['status', '--porcelain']).split('\n').filter(Boolean); @@ -357,9 +358,8 @@ export class FleetEvolutionService { } commitConfiguration(change, plan, ignore) { - const configPath = join(this.repositoryRoot, '.torch', 'torch.yaml'); const rosterPath = join(this.repositoryRoot, '.torch', 'roster.yaml'); - const config = readJson(configPath, 'CONFIG_INVALID'); + const config = loadProjectConfig(this.repositoryRoot); const roster = readJson(rosterPath, 'ROSTER_INVALID'); const manifest = readInstallManifest(this.repositoryRoot); const domain = change.domain; diff --git a/src/kernel/config.mjs b/src/kernel/config.mjs new file mode 100644 index 0000000..9106635 --- /dev/null +++ b/src/kernel/config.mjs @@ -0,0 +1,167 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { z } from 'zod'; +import { TorchError } from './errors.mjs'; + +export const PROJECT_CONFIG_SCHEMA = 'torch.dev/v1alpha1'; + +const text = z.string().trim().min(1); +const id = text.regex(/^[a-z0-9][a-z0-9-]*$/); +const textList = z.array(text); + +const domain = z.object({ + id, + title: text, + kind: text.optional(), + scope: textList.min(1), + not_scope: textList, + owned_paths: textList.min(1), + shared_paths: textList, + neighbours: textList, + required_checks: textList, + resources: textList, + runtime: text, + model: text.nullable().optional(), + branch: text.nullable().optional(), + worktree_name: text.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/).nullable().optional(), +}).strict(); + +const check = z.object({ + id, + title: text.optional(), + command: text, + args: textList.optional(), + source: z.record(z.string(), z.unknown()).optional(), + resources: textList.optional(), +}).strict(); + +const resource = z.object({ + id, + capacity: z.number().int().positive(), + queue: z.literal('fifo'), + max_hold_seconds: z.number().int().positive().optional(), + evidence: text.optional(), +}).strict(); + +const schedule = z.object({ + id, + title: text.optional(), + owner: text, + lifetime: z.enum(['system', 'session']), + trigger: z.discriminatedUnion('type', [ + z.object({ type: z.literal('manual') }).strict(), + z.object({ type: z.literal('interval'), seconds: z.number().int().positive() }).strict(), + z.object({ type: z.literal('cron'), expression: text }).strict(), + ]), + behavior: z.enum(['read-only', 'mutating']), + action: z.object({ type: z.literal('command'), command: text, args: textList }).strict(), + required_authority: textList.min(1), + retry: z.object({ max_attempts: z.number().int().min(1).max(10) }).strict().optional(), + failure_recipient: text.optional(), + source_of_truth: text, + detected: z.boolean().optional(), +}).strict(); + +export const projectConfigSchema = z.object({ + schema: z.literal(PROJECT_CONFIG_SCHEMA), + project: z.object({ id, name: text, main_branch: text }).strict(), + paths: z.object({ tracked_state: z.literal('.torch'), worktree_parent: text }).strict(), + repository: z.object({ + canonical: z.discriminatedUnion('type', [ + z.object({ type: z.literal('unconfigured') }).strict(), + z.object({ type: z.literal('remote'), remote: text }).strict(), + z.object({ type: z.literal('local'), remote: text, path: text }).strict(), + ]), + }).strict(), + forge: z.object({ provider: text }).passthrough(), + git: z.object({ + branch_prefix: text, + convergence: z.literal('merge'), + allow_rebase: z.literal(false), + allow_force_push: z.literal(false), + allow_bare_stash: z.literal(false), + }).strict(), + synchronization: z.object({ + strategy: text, + auto_merge_worktrees: z.literal(false), + }).passthrough(), + runtimes: z.record(z.string(), z.unknown()).refine((value) => typeof value.default === 'string', { + message: 'runtimes.default must name a configured runtime', + }), + checks: z.array(check), + resources: z.array(resource), + schedules: z.array(schedule), + integration: z.object({ + provider: text, + target: text, + require_current_main: z.boolean(), + required_checks: textList, + landing_authority: textList.min(1), + }).strict(), + session_manager: z.object({ + id: z.literal('session-manager'), + runtime: text, + start_last: z.literal(true), + branch: text.nullable().optional(), + worktree_name: text.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/).nullable().optional(), + }).strict(), + domains: z.array(domain).min(1), +}).strict().superRefine((config, context) => { + const runtimeNames = new Set(Object.keys(config.runtimes).filter((name) => name !== 'default')); + if (!runtimeNames.has(config.runtimes.default)) { + context.addIssue({ code: 'custom', path: ['runtimes', 'default'], message: 'default runtime is not configured' }); + } + for (const [index, entry] of config.domains.entries()) { + if (!runtimeNames.has(entry.runtime)) { + context.addIssue({ code: 'custom', path: ['domains', index, 'runtime'], message: 'domain runtime is not configured' }); + } + } +}); + +function formatIssues(issues) { + return issues.map((issue) => ({ + path: issue.path.length ? issue.path.join('.') : '', + message: issue.message, + code: issue.code, + })); +} + +export function validateProjectConfig(value) { + if (value?.schema !== PROJECT_CONFIG_SCHEMA) { + throw new TorchError(`Unsupported TORCH project configuration schema: ${value?.schema ?? ''}`, { + code: 'CONFIG_SCHEMA_UNSUPPORTED', + details: { actual: value?.schema ?? null, supported: [PROJECT_CONFIG_SCHEMA] }, + }); + } + const result = projectConfigSchema.safeParse(value); + if (!result.success) { + throw new TorchError('TORCH project configuration is invalid', { + code: 'CONFIG_SCHEMA_INVALID', details: formatIssues(result.error.issues), + }); + } + return value; +} + +export function loadProjectConfig(repositoryRoot) { + const path = join(repositoryRoot, '.torch', 'torch.yaml'); + let value; + try { value = JSON.parse(readFileSync(path, 'utf8')); } catch (error) { + throw new TorchError(`Cannot read ${path}`, { code: 'CONFIG_INVALID', details: error.message }); + } + return validateProjectConfig(value); +} + +export function planProjectConfigMigration(value) { + if (value?.schema === PROJECT_CONFIG_SCHEMA) { + validateProjectConfig(value); + return { + from: PROJECT_CONFIG_SCHEMA, to: PROJECT_CONFIG_SCHEMA, + steps: [], canProceed: true, changed: false, mutationPerformed: false, + }; + } + return { + from: value?.schema ?? null, to: PROJECT_CONFIG_SCHEMA, steps: [], canProceed: false, + changed: false, mutationPerformed: false, + blockers: [{ code: 'CONFIG_MIGRATION_UNAVAILABLE', schema: value?.schema ?? null }], + }; +} diff --git a/src/kernel/doctor.mjs b/src/kernel/doctor.mjs index 667ecc0..ba0bda1 100644 --- a/src/kernel/doctor.mjs +++ b/src/kernel/doctor.mjs @@ -4,6 +4,7 @@ import { DatabaseSync } from 'node:sqlite'; import { fileHash } from './files.mjs'; import { projectStatePath } from './paths.mjs'; import { inspectManagedWorktree } from './worktree-state.mjs'; +import { validateProjectConfig } from './config.mjs'; function parseJsonYaml(path) { return JSON.parse(readFileSync(path, 'utf8')); @@ -24,8 +25,8 @@ export function diagnoseProject({ repository, env = process.env }) { let config; let manifest; - try { config = parseJsonYaml(configPath); } catch (error) { - findings.push({ severity: 'error', code: 'CONFIG_INVALID', message: error.message }); + try { config = validateProjectConfig(parseJsonYaml(configPath)); } catch (error) { + findings.push({ severity: 'error', code: error.code ?? 'CONFIG_INVALID', message: error.message, details: error.details }); } try { manifest = parseJsonYaml(manifestPath); } catch (error) { findings.push({ severity: 'error', code: 'MANIFEST_INVALID', message: error.message }); diff --git a/src/kernel/worktrees.mjs b/src/kernel/worktrees.mjs index a4f0611..29a90bf 100644 --- a/src/kernel/worktrees.mjs +++ b/src/kernel/worktrees.mjs @@ -3,6 +3,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } fr import { homedir } from 'node:os'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import { TorchError } from './errors.mjs'; +import { loadProjectConfig } from './config.mjs'; import { readInstallManifest, writeInstallManifest } from './install.mjs'; function git(root, args, { optional = false } = {}) { @@ -25,7 +26,7 @@ function parseJson(path, code) { } export function loadFleetDefinition(root) { - const config = parseJson(join(root, '.torch', 'torch.yaml'), 'CONFIG_INVALID'); + const config = loadProjectConfig(root); const roster = parseJson(join(root, '.torch', 'roster.yaml'), 'ROSTER_INVALID'); return { config, roster }; } diff --git a/src/observability/snapshot.mjs b/src/observability/snapshot.mjs index c509637..2aebe47 100644 --- a/src/observability/snapshot.mjs +++ b/src/observability/snapshot.mjs @@ -6,6 +6,7 @@ import { diagnoseProject } from '../kernel/doctor.mjs'; import { inspectRepository } from '../kernel/git.mjs'; import { readInstallManifest } from '../kernel/install.mjs'; import { projectStatePath } from '../kernel/paths.mjs'; +import { loadProjectConfig } from '../kernel/config.mjs'; function json(path) { return JSON.parse(readFileSync(path, 'utf8')); @@ -55,7 +56,7 @@ export function observeProject({ repositoryRoot, env = process.env, now = () => }; } - const config = json(join(repository.root, '.torch', 'torch.yaml')); + const config = loadProjectConfig(repository.root); const roster = json(join(repository.root, '.torch', 'roster.yaml')); const manifest = readInstallManifest(repository.root); const stateRoot = projectStatePath(manifest.projectId, env); diff --git a/test/fleet/kernel-lifecycle.test.mjs b/test/fleet/kernel-lifecycle.test.mjs index e33e141..bab4de5 100644 --- a/test/fleet/kernel-lifecycle.test.mjs +++ b/test/fleet/kernel-lifecycle.test.mjs @@ -7,6 +7,9 @@ import test from 'node:test'; import { analyzeRepository } from '../../src/kernel/analyze.mjs'; import { diagnoseProject } from '../../src/kernel/doctor.mjs'; import { inspectRepository } from '../../src/kernel/git.mjs'; +import { + PROJECT_CONFIG_SCHEMA, planProjectConfigMigration, validateProjectConfig, +} from '../../src/kernel/config.mjs'; import { installProject, planInstall, uninstallProject } from '../../src/kernel/install.mjs'; import { proposeDomains, validateApprovedProposal } from '../../src/kernel/domains.mjs'; @@ -105,3 +108,33 @@ test('SCN-domain-approval: installation rejects stale or unapproved organization ); assert.equal(existsSync(join(root, '.torch')), false); }); + +test('SCN-config-schema: installed configuration is strict, versioned, and migration-aware', () => { + const { root, env } = fixture(); + const repository = inspectRepository(root); + installProject({ repository, proposal: approvedProposal(repository), env, projectId: 'schema-project' }); + const config = JSON.parse(readFileSync(join(root, '.torch', 'torch.yaml'), 'utf8')); + + assert.equal(validateProjectConfig(config), config); + assert.deepEqual(planProjectConfigMigration(config), { + from: PROJECT_CONFIG_SCHEMA, to: PROJECT_CONFIG_SCHEMA, + steps: [], canProceed: true, changed: false, mutationPerformed: false, + }); + + assert.throws( + () => validateProjectConfig({ ...config, schema: 'torch.dev/v2' }), + (error) => error.code === 'CONFIG_SCHEMA_UNSUPPORTED' + && error.details.supported.includes(PROJECT_CONFIG_SCHEMA), + ); + assert.throws( + () => validateProjectConfig({ ...config, future_required_policy: true }), + (error) => error.code === 'CONFIG_SCHEMA_INVALID' + && error.details.some((issue) => issue.path === '' && issue.code === 'unrecognized_keys'), + ); + assert.throws( + () => validateProjectConfig({ ...config, git: { ...config.git, allow_rebase: true } }), + (error) => error.code === 'CONFIG_SCHEMA_INVALID' + && error.details.some((issue) => issue.path === 'git.allow_rebase'), + ); + assert.equal(planProjectConfigMigration({ schema: 'torch.dev/v2' }).canProceed, false); +});