Validate versioned fleet configuration

This commit is contained in:
thePR0M3TH3AN committed 2026-09-27 17:53:08 -04:00
1 parent e9089ac5af
commit b015c6a758
11 files changed
+222 -11

No files matched your search

+1
View File
@@ -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;
+1
View File
@@ -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",
@@ -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
+3 -2
View File
@@ -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,
};
}
+2 -1
View File
@@ -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', {
+4 -4
View File
@@ -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;
+167
View File
@@ -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('.') : '<root>',
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 ?? '<missing>'}`, {
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 }],
};
}
+3 -2
View File
@@ -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 });
+2 -1
View File
@@ -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 };
}
+2 -1
View File
@@ -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);
+33
View File
@@ -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 === '<root>' && 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);
});