diff --git a/README.md b/README.md index 65af0d4..c483a1d 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,10 @@ and the first Claude reference-fleet path: persistent domain, while owner approval gates the configuration commit, identity, prompt, branch, and worktree activation; it can also propose safe retirement when coordination cost exceeds continuing value, preserving the - branch and refusing live, dirty, guarded, leased, or active work. + branch and refusing live, dirty, guarded, leased, or active work. Evidence- + backed merge and split proposals are durable and owner-gated, but approval + deliberately cannot rewrite ownership: TORCH reports the separate migration + proof still required for backlog, branches, prompts, worktrees, and neighbours. Live mixed-provider qualification, stable self-host bootstrapping, COMBATRIG cutover, installation of the implemented system-schedule launcher on this 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 364e58c..6dd28e5 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 @@ -170,6 +170,10 @@ test_integrity_note: given: a persistent specialist whose recurring work no longer justifies its coordination cost when: the Session Manager proposes retirement and the owner approves it then: live, dirty, guarded, leased, uniquely committed, or actively assigned work blocks retirement; safe activation removes the worktree and identity while preserving its branch and durable decision + - id: SCN-fleet-boundary-evolution + given: recurring work suggests an existing pair of domains should merge or one domain should split + when: the Session Manager submits complete ownership assignments and the owner reviews the durable proposal + then: invalid or stale boundaries are refused and approval cannot silently mutate identities, ownership, prompts, branches, or worktrees - id: SCN-package-distribution given: a clean TORCH source checkout and an isolated package consumer when: npm builds and installs the published artifact without registry access diff --git a/src/cli.mjs b/src/cli.mjs index cafe14a..9df72b6 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -109,7 +109,10 @@ Usage: torch fleet get --change [--json] torch fleet propose --from session-manager --proposal [--json] torch fleet propose-retirement --from session-manager --proposal [--json] + torch fleet propose-merge --from session-manager --proposal [--json] + torch fleet propose-split --from session-manager --proposal [--json] torch fleet approve --change --by --yes [--json] + torch fleet reject --change --by --reason --yes [--json] torch fleet plan --change [--json] torch fleet activate --change --by --yes [--json] torch fleet start --change [--fresh] [--dry-run] --yes [--json] @@ -200,6 +203,7 @@ function runCandidateAcceptance(candidateRoot, spawn) { ['ai-fleet-bootstrap', ['SCN-ai-fleet-bootstrap']], ['ai-fleet-planning', ['SCN-ai-fleet-planning']], ['fleet-evolution', ['SCN-fleet-evolution', 'SCN-cli-fleet-evolution']], + ['fleet-boundary-evolution', ['SCN-fleet-boundary-evolution']], ['review-install-roster', ['SCN-cli-domain-review']], ['branches-worktrees', ['SCN-worktree-bootstrap']], ['runtime-identities', ['SCN-mixed-runtime']], @@ -637,11 +641,37 @@ export async function runCli(argv = process.argv.slice(2), { ...input, areaId: input.area_id ?? input.areaId, proposer: optionValue(argv, '--from') ?? input.proposer, }), { json }); + } else if (operation === 'propose-merge' || operation === 'propose-split') { + const input = loadJsonFile(cwd, optionValue(argv, '--proposal'), { + label: 'Fleet boundary proposal', code: 'FLEET_PROPOSAL_INVALID', + }); + const request = { + ...input, + proposer: optionValue(argv, '--from') ?? input.proposer, + sourceDomains: input.sourceDomains ?? input.source_domains, + resultDomains: input.resultDomains ?? input.result_domains, + ownershipAssignments: input.ownershipAssignments ?? input.ownership_assignments, + expectedBenefit: input.expectedBenefit ?? (input.expected_benefit ? { + summary: input.expected_benefit.summary, + recurringWork: input.expected_benefit.recurring_work, + contextLocality: input.expected_benefit.context_locality, + coordinationCost: input.expected_benefit.coordination_cost, + } : undefined), + }; + print(operation === 'propose-merge' + ? evolution.proposeMerge(request) : evolution.proposeSplit(request), { json }); } else if (operation === 'approve') { if (!argv.includes('--yes')) { throw new TorchError('Owner approval of a Fleet change requires explicit --yes.', { code: 'APPROVAL_REQUIRED' }); } print(evolution.approve({ changeId, approvedBy: optionValue(argv, '--by') }), { json }); + } else if (operation === 'reject') { + if (!argv.includes('--yes')) { + throw new TorchError('Owner rejection of a Fleet change requires explicit --yes.', { code: 'APPROVAL_REQUIRED' }); + } + print(evolution.reject({ + changeId, rejectedBy: optionValue(argv, '--by'), reason: optionValue(argv, '--reason'), + }), { json }); } else if (operation === 'plan') print(evolution.planActivation(changeId), { json }); else if (operation === 'activate') { if (!argv.includes('--yes')) { diff --git a/src/evolution/service.mjs b/src/evolution/service.mjs index 212ae3a..4b64be9 100644 --- a/src/evolution/service.mjs +++ b/src/evolution/service.mjs @@ -115,7 +115,10 @@ function rowToChange(row) { id: row.id, type: row.change_type ?? 'add-domain', state: row.state, proposer: row.proposer, createdAt: row.created_at, updatedAt: row.updated_at, baseCommit: row.base_commit, approvedBy: row.approved_by, approvedAt: row.approved_at, + rejectedBy: row.rejected_by ?? null, rejectedAt: row.rejected_at ?? null, + rejectionReason: row.rejection_reason ?? null, activationCommit: row.activation_commit, domain: JSON.parse(row.domain_json), + proposal: row.proposal_json ? JSON.parse(row.proposal_json) : null, rationale: row.rationale, expectedBenefit: JSON.parse(row.expected_benefit_json), evidence: JSON.parse(row.evidence_json), }; @@ -148,11 +151,15 @@ function initialize(database) { base_commit TEXT NOT NULL, approved_by TEXT, approved_at TEXT, + rejected_by TEXT, + rejected_at TEXT, + rejection_reason TEXT, activation_commit TEXT, domain_json TEXT NOT NULL, rationale TEXT NOT NULL, expected_benefit_json TEXT NOT NULL, - evidence_json TEXT NOT NULL + evidence_json TEXT NOT NULL, + proposal_json TEXT ); CREATE INDEX IF NOT EXISTS fleet_changes_state_created ON fleet_changes(state, created_at, id); @@ -161,6 +168,12 @@ function initialize(database) { if (!columns.has('change_type')) { database.exec("ALTER TABLE fleet_changes ADD COLUMN change_type TEXT NOT NULL DEFAULT 'add-domain'"); } + if (!columns.has('proposal_json')) { + database.exec('ALTER TABLE fleet_changes ADD COLUMN proposal_json TEXT'); + } + if (!columns.has('rejected_by')) database.exec('ALTER TABLE fleet_changes ADD COLUMN rejected_by TEXT'); + if (!columns.has('rejected_at')) database.exec('ALTER TABLE fleet_changes ADD COLUMN rejected_at TEXT'); + if (!columns.has('rejection_reason')) database.exec('ALTER TABLE fleet_changes ADD COLUMN rejection_reason TEXT'); } function expectedBenefit(input) { @@ -176,6 +189,22 @@ function hasTable(database, name) { return Boolean(database.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)); } +function configDigest(repositoryRoot) { + return sha256(readFileSync(join(repositoryRoot, '.torch', 'torch.yaml'))); +} + +function normalizeAssignment(input) { + return { + sourceDomainId: requiredText(input?.sourceDomainId ?? input?.source_domain_id, 'ownershipAssignments.sourceDomainId'), + sourcePath: requiredText(input?.sourcePath ?? input?.source_path, 'ownershipAssignments.sourcePath'), + resultDomainId: requiredText(input?.resultDomainId ?? input?.result_domain_id, 'ownershipAssignments.resultDomainId'), + }; +} + +function stableDomain(domain) { + return JSON.stringify(domain); +} + export class FleetEvolutionService { constructor({ repositoryRoot, controlPlane, clock = () => new Date(), idFactory = randomUUID } = {}) { if (!controlPlane) throw new TorchError('Fleet evolution requires the TORCH control plane', { code: 'CONTROL_PLANE_REQUIRED' }); @@ -310,6 +339,164 @@ export class FleetEvolutionService { return record; } + proposeMerge(input = {}) { + return this.proposeBoundaryChange('merge-domains', input); + } + + proposeSplit(input = {}) { + return this.proposeBoundaryChange('split-domain', input); + } + + proposeBoundaryChange(changeType, { + proposer, sourceDomains: sourceInput, resultDomains: resultInput, + ownershipAssignments: assignmentInput, rationale, expectedBenefit: benefitInput, evidence, + } = {}) { + const actor = this.controlPlane.assertIdentity(proposer); + if (actor !== 'session-manager') { + throw new TorchError('Only the Session Manager may propose changing persistent Fleet boundaries', { + code: 'FLEET_CHANGE_AUTHORITY_REQUIRED', details: { actor }, + }); + } + if (!['merge-domains', 'split-domain'].includes(changeType)) { + throw new TorchError(`Unsupported Fleet boundary change: ${changeType}`, { code: 'FLEET_CHANGE_INVALID' }); + } + const sourceIds = textList(sourceInput, 'sourceDomains', { required: true }); + if ((changeType === 'merge-domains' && sourceIds.length < 2) + || (changeType === 'split-domain' && sourceIds.length !== 1)) { + throw new TorchError(`${changeType} has invalid source-domain cardinality`, { + code: 'FLEET_CHANGE_INVALID', details: { sourceDomains: sourceIds }, + }); + } + if (sourceIds.includes('session-manager')) { + throw new TorchError('The primary Session Manager is not a merge or split source', { code: 'FLEET_CHANGE_INVALID' }); + } + const config = loadProjectConfig(this.repositoryRoot); + const configuredById = new Map(config.domains.map((domain) => [domain.id, domain])); + const unknownSources = sourceIds.filter((id) => !configuredById.has(id)); + if (unknownSources.length) { + throw new TorchError('Fleet boundary proposal references unknown source domains', { + code: 'FLEET_CHANGE_INVALID', details: { sourceDomains: unknownSources }, + }); + } + if (!Array.isArray(resultInput)) { + throw new TorchError('resultDomains must be an array', { + code: 'INVALID_FLEET_CHANGE', details: { field: 'resultDomains' }, + }); + } + const resultDomains = resultInput.map(normalizeDomain); + if ((changeType === 'merge-domains' && resultDomains.length !== 1) + || (changeType === 'split-domain' && resultDomains.length < 2)) { + throw new TorchError(`${changeType} has invalid result-domain cardinality`, { + code: 'FLEET_CHANGE_INVALID', details: { resultDomains: resultDomains.map((domain) => domain.id) }, + }); + } + const resultIds = resultDomains.map((domain) => domain.id); + if (new Set(resultIds).size !== resultIds.length) { + throw new TorchError('Fleet boundary proposal has duplicate result-domain IDs', { code: 'FLEET_CHANGE_INVALID' }); + } + const collisions = resultIds.filter((id) => configuredById.has(id) && !sourceIds.includes(id)); + if (collisions.length) { + throw new TorchError('Result-domain IDs collide with unaffected Fleet identities', { + code: 'FLEET_IDENTITY_EXISTS', details: { domains: collisions }, + }); + } + const knownFutureIds = new Set([ + 'session-manager', ...config.domains.filter((domain) => !sourceIds.includes(domain.id)).map((domain) => domain.id), + ...resultIds, + ]); + const knownRuntimes = new Set(Object.keys(config.runtimes ?? {})); + const knownChecks = new Set((config.checks ?? []).map((check) => check.id)); + const knownResources = new Set((config.resources ?? []).map((resource) => resource.id)); + for (const domain of resultDomains) { + const invalid = { + neighbours: domain.neighbours.filter((id) => !knownFutureIds.has(id)), + checks: domain.required_checks.filter((id) => !knownChecks.has(id)), + resources: domain.resources.filter((id) => !knownResources.has(id)), + }; + if (!knownRuntimes.has(domain.runtime) || Object.values(invalid).some((items) => items.length)) { + throw new TorchError('Result domain references Fleet configuration that will not exist', { + code: 'FLEET_CHANGE_INVALID', details: { domainId: domain.id, runtime: domain.runtime, ...invalid }, + }); + } + } + const assignments = Array.isArray(assignmentInput) ? assignmentInput.map(normalizeAssignment) : []; + const expectedOwnership = sourceIds.flatMap((id) => configuredById.get(id).owned_paths + .map((path) => `${id}\0${path}`)); + const assignedOwnership = assignments.map((assignment) => `${assignment.sourceDomainId}\0${assignment.sourcePath}`); + if (assignments.some((assignment) => !sourceIds.includes(assignment.sourceDomainId) + || !resultIds.includes(assignment.resultDomainId)) + || new Set(assignedOwnership).size !== assignedOwnership.length + || expectedOwnership.length !== assignedOwnership.length + || expectedOwnership.some((entry) => !assignedOwnership.includes(entry))) { + throw new TorchError('Every source ownership pattern must be assigned exactly once to a result domain', { + code: 'FLEET_OWNERSHIP_ASSIGNMENT_INVALID', + details: { expected: expectedOwnership, assigned: assignedOwnership }, + }); + } + const resultOwnership = resultDomains.flatMap((domain) => domain.owned_paths + .map((path) => ({ domainId: domain.id, path }))); + const assignedResults = assignments.map((assignment) => `${assignment.resultDomainId}\0${assignment.sourcePath}`); + const declaredResults = resultOwnership.map((entry) => `${entry.domainId}\0${entry.path}`); + if (new Set(declaredResults).size !== declaredResults.length + || assignedResults.length !== declaredResults.length + || assignedResults.some((entry) => !declaredResults.includes(entry))) { + throw new TorchError('Result owned paths must exactly match the explicit ownership assignments', { + code: 'FLEET_OWNERSHIP_ASSIGNMENT_INVALID', details: { assigned: assignedResults, declared: declaredResults }, + }); + } + for (let left = 0; left < resultOwnership.length; left += 1) { + for (let right = left + 1; right < resultOwnership.length; right += 1) { + if (resultOwnership[left].domainId !== resultOwnership[right].domainId + && pathsOverlap(resultOwnership[left].path, resultOwnership[right].path)) { + throw new TorchError('Result domains have overlapping ownership', { + code: 'FLEET_OWNERSHIP_COLLISION', details: [resultOwnership[left], resultOwnership[right]], + }); + } + } + } + const unaffected = config.domains.filter((domain) => !sourceIds.includes(domain.id)); + const externalCollisions = resultOwnership.flatMap((result) => unaffected.flatMap((domain) => + domain.owned_paths.filter((path) => pathsOverlap(result.path, path)) + .map((path) => ({ resultDomainId: result.domainId, resultPath: result.path, areaId: domain.id, existingPath: path })))); + if (externalCollisions.length) { + throw new TorchError('Result ownership overlaps an unaffected Fleet domain', { + code: 'FLEET_OWNERSHIP_COLLISION', details: externalCollisions, + }); + } + const now = this.clock().toISOString(); + const proposal = { + schema: 'torch.dev/fleet-boundary-proposal/v1alpha1', + operation: changeType === 'merge-domains' ? 'merge' : 'split', + configDigest: configDigest(this.repositoryRoot), + sourceDomains: sourceIds.map((id) => configuredById.get(id)), + resultDomains, ownershipAssignments: assignments, + }; + const record = { + id: this.idFactory(), type: changeType, state: 'proposed', proposer: actor, + createdAt: now, updatedAt: now, baseCommit: git(this.repositoryRoot, ['rev-parse', 'HEAD']), + approvedBy: null, approvedAt: null, activationCommit: null, domain: null, proposal, + rationale: requiredText(rationale, 'rationale'), expectedBenefit: expectedBenefit(benefitInput), + evidence: textList(evidence, 'evidence', { required: true }), + }; + this.database.prepare(` + INSERT INTO fleet_changes ( + id, change_type, state, proposer, created_at, updated_at, base_commit, approved_by, + approved_at, activation_commit, domain_json, rationale, expected_benefit_json, evidence_json, + proposal_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, 'null', ?, ?, ?, ?) + `).run( + record.id, record.type, record.state, record.proposer, record.createdAt, record.updatedAt, + record.baseCommit, record.rationale, JSON.stringify(record.expectedBenefit), + JSON.stringify(record.evidence), JSON.stringify(proposal), + ); + this.controlPlane.audit({ + actorId: actor, operation: `fleet-change.propose-${proposal.operation}`, + entityType: 'fleet-change', entityId: record.id, + details: { sourceDomains: sourceIds, resultDomains: resultIds, baseCommit: record.baseCommit }, + }); + return record; + } + approve({ changeId, approvedBy } = {}) { const change = this.get(changeId); if (change.state !== 'proposed') { @@ -328,9 +515,32 @@ export class FleetEvolutionService { return this.get(change.id); } + reject({ changeId, rejectedBy, reason } = {}) { + const change = this.get(changeId); + if (change.state !== 'proposed') { + throw new TorchError(`Fleet change ${change.id} is ${change.state}, not proposed`, { code: 'FLEET_CHANGE_STATE_CONFLICT' }); + } + const owner = requiredText(rejectedBy, 'rejectedBy'); + const explanation = requiredText(reason, 'reason'); + const now = this.clock().toISOString(); + this.database.prepare(` + UPDATE fleet_changes SET state = 'rejected', rejected_by = ?, rejected_at = ?, + rejection_reason = ?, updated_at = ? + WHERE id = ? AND state = 'proposed' + `).run(owner, now, explanation, now, change.id); + this.controlPlane.audit({ + actorId: 'session-manager', operation: 'fleet-change.owner-rejected', entityType: 'fleet-change', entityId: change.id, + details: { rejectedBy: owner, reason: explanation }, + }); + return this.get(change.id); + } + planActivation(changeId) { const change = this.get(changeId); if (change.type === 'retire-domain') return this.planRetirement(change); + if (change.type === 'merge-domains' || change.type === 'split-domain') { + return this.planBoundaryChange(change); + } const config = loadProjectConfig(this.repositoryRoot); const roster = readJson(join(this.repositoryRoot, '.torch', 'roster.yaml'), 'ROSTER_INVALID'); const currentHead = git(this.repositoryRoot, ['rev-parse', 'HEAD']); @@ -375,6 +585,11 @@ export class FleetEvolutionService { }); } if (change.type === 'retire-domain') return this.retire(change, approvedBy); + if (change.type === 'merge-domains' || change.type === 'split-domain') { + throw new TorchError('Merge and split approval does not authorize automatic ownership migration', { + code: 'FLEET_BOUNDARY_MIGRATION_REQUIRED', details: this.planBoundaryChange(change), + }); + } const plan = this.planActivation(change.id); if (!plan.canProceed) { throw new TorchError('Fleet domain activation is blocked', { @@ -411,6 +626,36 @@ export class FleetEvolutionService { }; } + planBoundaryChange(changeOrId) { + const change = typeof changeOrId === 'string' ? this.get(changeOrId) : changeOrId; + const currentHead = git(this.repositoryRoot, ['rev-parse', 'HEAD']); + const config = loadProjectConfig(this.repositoryRoot); + const configuredById = new Map(config.domains.map((domain) => [domain.id, domain])); + const proposal = change.proposal; + const blockers = []; + if (change.state !== 'approved') blockers.push({ type: 'state', actual: change.state, required: 'approved' }); + if (currentHead !== change.baseCommit) blockers.push({ type: 'stale-head', expected: change.baseCommit, actual: currentHead }); + const currentDigest = configDigest(this.repositoryRoot); + if (currentDigest !== proposal.configDigest) { + blockers.push({ type: 'configuration-changed', expected: proposal.configDigest, actual: currentDigest }); + } + for (const source of proposal.sourceDomains) { + const current = configuredById.get(source.id); + if (!current) blockers.push({ type: 'source-domain-missing', domainId: source.id }); + else if (stableDomain(current) !== stableDomain(source)) blockers.push({ type: 'source-domain-changed', domainId: source.id }); + } + blockers.push({ + type: 'manual-boundary-redesign-required', + reason: 'A separate migration must prove backlog, branch, prompt, worktree, neighbour, and ownership transfer safety.', + }); + return { + action: change.type, changeId: change.id, state: change.state, + sourceDomains: proposal.sourceDomains.map((domain) => domain.id), + resultDomains: proposal.resultDomains.map((domain) => domain.id), + currentHead, baseCommit: change.baseCommit, blockers, canProceed: false, mutationPerformed: false, + }; + } + planRetirement(changeOrId) { const change = typeof changeOrId === 'string' ? this.get(changeOrId) : changeOrId; const config = loadProjectConfig(this.repositoryRoot); diff --git a/src/mcp/tools.mjs b/src/mcp/tools.mjs index e60de5b..9585d9f 100644 --- a/src/mcp/tools.mjs +++ b/src/mcp/tools.mjs @@ -57,6 +57,8 @@ export const TORCH_MCP_TOOL_NAMES = Object.freeze([ 'torch_get_fleet_change', 'torch_propose_domain', 'torch_propose_domain_retirement', + 'torch_propose_domain_merge', + 'torch_propose_domain_split', ]); function claimedIdentity(controlPlane, actorId, claim, field) { @@ -456,6 +458,49 @@ export function createTorchToolset(controlPlane, { }, }), }); + const boundaryProposalSchema = { + proposer: z.string().min(1).optional(), + source_domains: z.array(z.string().min(1)).min(1), + result_domains: z.array(z.object({ + id: z.string().min(1), title: z.string().min(1), kind: z.string().min(1).optional(), + scope: z.array(z.string().min(1)).min(1), not_scope: z.array(z.string().min(1)).optional(), + owned_paths: z.array(z.string().min(1)).min(1), shared_paths: z.array(z.string().min(1)).optional(), + neighbours: z.array(z.string().min(1)).optional(), required_checks: z.array(z.string().min(1)).optional(), + resources: z.array(z.string().min(1)).optional(), runtime: z.string().min(1).optional(), + model: z.string().min(1).optional(), branch: z.string().min(1).optional(), + worktree_name: z.string().min(1).optional(), + })).min(1), + ownership_assignments: z.array(z.object({ + source_domain_id: z.string().min(1), source_path: z.string().min(1), result_domain_id: z.string().min(1), + })).min(1), + rationale: z.string().min(1), + expected_benefit: z.object({ + summary: z.string().min(1), recurring_work: z.string().min(1), + context_locality: z.string().min(1), coordination_cost: z.string().min(1), + }), + evidence: z.array(z.string().min(1)).min(1), + }; + const invokeBoundaryProposal = (operation) => ({ + proposer, source_domains: sourceDomains, result_domains: resultDomains, + ownership_assignments: ownershipAssignments, expected_benefit: benefit, ...input + }) => evolutionService[operation]({ + ...input, sourceDomains, resultDomains, ownershipAssignments, + proposer: claimedIdentity(controlPlane, actorId, proposer, 'proposer'), + expectedBenefit: { + summary: benefit.summary, recurringWork: benefit.recurring_work, + contextLocality: benefit.context_locality, coordinationCost: benefit.coordination_cost, + }, + }); + tools.set('torch_propose_domain_merge', { + description: 'Propose an evidence-backed merge of persistent domains. Approval records intent but cannot automatically migrate ownership.', + schema: boundaryProposalSchema, + invoke: invokeBoundaryProposal('proposeMerge'), + }); + tools.set('torch_propose_domain_split', { + description: 'Propose an evidence-backed split of a persistent domain. Approval records intent but cannot automatically migrate ownership.', + schema: boundaryProposalSchema, + invoke: invokeBoundaryProposal('proposeSplit'), + }); } return tools; } diff --git a/src/observability/snapshot.mjs b/src/observability/snapshot.mjs index 2aebe47..bd0447d 100644 --- a/src/observability/snapshot.mjs +++ b/src/observability/snapshot.mjs @@ -138,8 +138,9 @@ export function observeProject({ repositoryRoot, env = process.env, now = () => FROM integration_requests ORDER BY updated_at DESC LIMIT 40 `); result.fleetChanges = rows(database, 'fleet_changes', ` - SELECT id, state, proposer, json_extract(domain_json, '$.id') AS domainId, - json_extract(domain_json, '$.title') AS title, + SELECT id, change_type AS changeType, state, proposer, + COALESCE(json_extract(domain_json, '$.id'), json_extract(proposal_json, '$.resultDomains[0].id')) AS domainId, + COALESCE(json_extract(domain_json, '$.title'), json_extract(proposal_json, '$.resultDomains[0].title')) AS title, json_extract(expected_benefit_json, '$.summary') AS expectedBenefit, approved_by AS approvedBy, updated_at AS updatedAt FROM fleet_changes ORDER BY updated_at DESC, id LIMIT 40 diff --git a/src/self-host/service.mjs b/src/self-host/service.mjs index 28e21aa..6720346 100644 --- a/src/self-host/service.mjs +++ b/src/self-host/service.mjs @@ -13,6 +13,7 @@ export const CANDIDATE_ACCEPTANCE_SCENARIOS = Object.freeze([ 'ai-fleet-bootstrap', 'ai-fleet-planning', 'fleet-evolution', + 'fleet-boundary-evolution', 'review-install-roster', 'branches-worktrees', 'runtime-identities', diff --git a/test/fleet/cli-scenarios.test.mjs b/test/fleet/cli-scenarios.test.mjs index 30a8442..52c9d52 100644 --- a/test/fleet/cli-scenarios.test.mjs +++ b/test/fleet/cli-scenarios.test.mjs @@ -36,6 +36,7 @@ function releaseCandidate(root, version) { 'SCN-ai-fleet-bootstrap', 'SCN-ai-fleet-planning', 'SCN-fleet-evolution', 'SCN-cli-fleet-evolution', + 'SCN-fleet-boundary-evolution', '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/evolution.test.mjs b/test/fleet/evolution.test.mjs index d542d5f..7b33bb6 100644 --- a/test/fleet/evolution.test.mjs +++ b/test/fleet/evolution.test.mjs @@ -17,7 +17,7 @@ import { observeProject } from '../../src/observability/snapshot.mjs'; const CLI = new URL('../../bin/torch.mjs', import.meta.url).pathname; -function fixture() { +function fixture({ boundary = false } = {}) { const root = mkdtempSync(join(tmpdir(), 'torch-evolution-')); const worktreeParent = mkdtempSync(join(tmpdir(), 'torch-evolution-worktrees-')); const env = { ...process.env, XDG_DATA_HOME: mkdtempSync(join(tmpdir(), 'torch-evolution-state-')) }; @@ -29,6 +29,19 @@ function fixture() { execFileSync('git', ['-C', root, 'commit', '-m', 'fixture']); let repository = inspectRepository(root); const proposal = proposeDomains({ repository, analysis: analyzeRepository(repository) }); + if (boundary) { + const template = proposal.domains[0]; + proposal.domains = [ + { + ...template, id: 'alpha', title: 'Alpha', owned_paths: ['app.js', 'src/alpha/**'], + neighbours: ['beta'], evidence: ['app.js'], + }, + { + ...template, id: 'beta', title: 'Beta', owned_paths: ['src/beta/**'], + neighbours: ['alpha'], evidence: ['app.js'], + }, + ]; + } proposal.paths = { worktree_parent: worktreeParent }; proposal.review = { status: 'approved', reviewedAt: '2026-09-27T00:00:00Z', reviewedBy: 'fixture-owner', notes: [], @@ -40,6 +53,133 @@ function fixture() { return { root, env, repository, worktreeParent }; } +function boundaryBenefit() { + return { + summary: 'Align persistent sessions with the recurring implementation boundary.', + recurringWork: 'The same cross-domain changes recur across milestones.', + contextLocality: 'Keep only the context needed by each durable responsibility.', + coordinationCost: 'Reduce recurring handoffs without hiding the migration cost.', + }; +} + +test('SCN-fleet-boundary-evolution: merge and split proposals are durable, owner-gated, and non-mutating', () => { + const context = fixture({ boundary: true }); + const configPath = join(context.root, '.torch', 'torch.yaml'); + const rosterPath = join(context.root, '.torch', 'roster.yaml'); + const before = { config: readFileSync(configPath, 'utf8'), roster: readFileSync(rosterPath, 'utf8') }; + const control = openControlPlane({ repositoryRoot: context.root, env: context.env }); + let sequence = 0; + const evolution = new FleetEvolutionService({ + repositoryRoot: context.root, controlPlane: control, + clock: () => new Date('2026-09-27T14:00:00Z'), idFactory: () => `BOUNDARY-${++sequence}`, + }); + const benefit = boundaryBenefit(); + const mergeInput = { + sourceDomains: ['alpha', 'beta'], + resultDomains: [{ + id: 'platform', title: 'Platform', kind: 'development', + scope: ['combined alpha and beta implementation'], not_scope: ['Session Manager work'], + owned_paths: ['app.js', 'src/alpha/**', 'src/beta/**'], shared_paths: [], neighbours: [], + required_checks: [], resources: [], runtime: 'claude', + }], + ownershipAssignments: [ + { sourceDomainId: 'alpha', sourcePath: 'app.js', resultDomainId: 'platform' }, + { sourceDomainId: 'alpha', sourcePath: 'src/alpha/**', resultDomainId: 'platform' }, + { sourceDomainId: 'beta', sourcePath: 'src/beta/**', resultDomainId: 'platform' }, + ], + rationale: 'Alpha and beta now change together and retain mostly identical context.', + expectedBenefit: benefit, evidence: ['TASK-31', 'TASK-38'], + }; + assert.throws( + () => evolution.proposeMerge({ ...mergeInput, proposer: 'alpha' }), + (error) => error.code === 'FLEET_CHANGE_AUTHORITY_REQUIRED', + ); + assert.throws( + () => evolution.proposeMerge({ + ...mergeInput, proposer: 'session-manager', ownershipAssignments: mergeInput.ownershipAssignments.slice(1), + }), + (error) => error.code === 'FLEET_OWNERSHIP_ASSIGNMENT_INVALID', + ); + const merged = callTorchTool(control, 'torch_propose_domain_merge', { + proposer: 'session-manager', source_domains: mergeInput.sourceDomains, + result_domains: mergeInput.resultDomains.map((domain) => ({ + ...domain, scope: domain.scope, not_scope: domain.not_scope, + owned_paths: domain.owned_paths, shared_paths: domain.shared_paths, + required_checks: domain.required_checks, + })), + ownership_assignments: mergeInput.ownershipAssignments.map((assignment) => ({ + source_domain_id: assignment.sourceDomainId, source_path: assignment.sourcePath, + result_domain_id: assignment.resultDomainId, + })), + rationale: mergeInput.rationale, + expected_benefit: { + summary: benefit.summary, recurring_work: benefit.recurringWork, + context_locality: benefit.contextLocality, coordination_cost: benefit.coordinationCost, + }, + evidence: mergeInput.evidence, + }, { actorId: 'session-manager', evolutionService: evolution }); + assert.equal(merged.type, 'merge-domains'); + assert.equal(merged.proposal.schema, 'torch.dev/fleet-boundary-proposal/v1alpha1'); + evolution.approve({ changeId: merged.id, approvedBy: 'fixture-owner' }); + const plan = evolution.planActivation(merged.id); + assert.equal(plan.canProceed, false); + assert.equal(plan.blockers.some((blocker) => blocker.type === 'manual-boundary-redesign-required'), true); + writeFileSync(configPath, `${before.config.trimEnd()} \n`); + assert.equal(evolution.planActivation(merged.id).blockers + .some((blocker) => blocker.type === 'configuration-changed'), true); + writeFileSync(configPath, before.config); + assert.throws( + () => evolution.activate({ changeId: merged.id, approvedBy: 'fixture-owner' }), + (error) => error.code === 'FLEET_BOUNDARY_MIGRATION_REQUIRED', + ); + assert.equal(readFileSync(configPath, 'utf8'), before.config); + assert.equal(readFileSync(rosterPath, 'utf8'), before.roster); + assert.deepEqual(control.listAgents().map((agent) => agent.areaId).sort(), ['alpha', 'beta', 'session-manager']); + control.close(); + + const splitPath = join(context.root, 'split-proposal.json'); + writeFileSync(splitPath, `${JSON.stringify({ + sourceDomains: ['alpha'], + resultDomains: [ + { + id: 'alpha-app', title: 'Alpha app', scope: ['application entrypoint'], not_scope: ['alpha internals'], + owned_paths: ['app.js'], shared_paths: [], neighbours: ['alpha-internals'], required_checks: [], resources: [], runtime: 'claude', + }, + { + id: 'alpha-internals', title: 'Alpha internals', scope: ['alpha internals'], not_scope: ['application entrypoint'], + owned_paths: ['src/alpha/**'], shared_paths: [], neighbours: ['alpha-app'], required_checks: [], resources: [], runtime: 'claude', + }, + ], + ownershipAssignments: [ + { sourceDomainId: 'alpha', sourcePath: 'app.js', resultDomainId: 'alpha-app' }, + { sourceDomainId: 'alpha', sourcePath: 'src/alpha/**', resultDomainId: 'alpha-internals' }, + ], + rationale: 'Alpha now contains two recurring responsibilities with different context.', + expectedBenefit: benefit, evidence: ['TASK-44', 'ownership review'], + }, null, 2)}\n`); + const run = (args) => spawnSync(process.execPath, [CLI, ...args], { + cwd: context.root, env: context.env, encoding: 'utf8', + }); + const proposed = run(['fleet', 'propose-split', '--from', 'session-manager', '--proposal', splitPath, '--json']); + assert.equal(proposed.status, 0, proposed.stderr || proposed.stdout); + const split = JSON.parse(proposed.stdout); + assert.equal(split.type, 'split-domain'); + const refused = run(['fleet', 'reject', '--change', split.id, '--by', 'fixture-owner', '--reason', 'Wait for milestone.', '--json']); + assert.equal(refused.status, 2); + assert.equal(JSON.parse(refused.stdout).error, 'APPROVAL_REQUIRED'); + const rejected = run(['fleet', 'reject', '--change', split.id, '--by', 'fixture-owner', '--reason', 'Wait for milestone.', '--yes', '--json']); + assert.equal(rejected.status, 0, rejected.stderr || rejected.stdout); + assert.equal(JSON.parse(rejected.stdout).state, 'rejected'); + assert.equal(JSON.parse(rejected.stdout).rejectionReason, 'Wait for milestone.'); + const reopened = openControlPlane({ repositoryRoot: context.root, env: context.env }); + const persisted = new FleetEvolutionService({ repositoryRoot: context.root, controlPlane: reopened }).get(merged.id); + assert.equal(persisted.type, 'merge-domains'); + assert.equal(persisted.proposal.resultDomains[0].id, 'platform'); + assert.equal(new FleetEvolutionService({ repositoryRoot: context.root, controlPlane: reopened }) + .get(split.id).rejectionReason, 'Wait for milestone.'); + reopened.close(); +}); + test('SCN-fleet-evolution: the manager proposes and owner activates a newly justified persistent domain', () => { const context = fixture(); const control = openControlPlane({ repositoryRoot: context.root, env: context.env });