mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-06 12:51:54 +00:00
Add guarded fleet boundary proposals
This commit is contained in:
1 parent
175d341e60
commit
3e988a295c
9 files changed
+475
-5
No files matched your search
+246
-1
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user