diff --git a/docs/TEST_INTEGRITY.md b/docs/TEST_INTEGRITY.md index 8f75873..d09b695 100644 --- a/docs/TEST_INTEGRITY.md +++ b/docs/TEST_INTEGRITY.md @@ -2883,6 +2883,45 @@ test_integrity_note: if_true_explain_spec_basis: "" ``` +# Candidate source B2 frozen-input and fence boundaries (2026-10-01) + +```yaml +test_integrity_note: + change_type: new_tests + scenarios: + - id: SCN-candidate-bootstrap-one-shot + given: "An issued, nonce-bound native admission" + when: "It is opened, replayed, expired, foreign, or malformed" + then: "Only one bounded private bootstrap opens; every invalid case refuses before child start and exposes no authority handle" + - id: SCN-candidate-input-consumption + given: "A frozen project-local source snapshot" + when: "The original source changes before the fixed Node child reads its cwd" + then: "The child observes the captured bytes; snapshot drift refuses" + - id: SCN-candidate-execution-boundary + given: "A fixed command and explicit bounded environment" + when: "Ambient PATH/HOME/cache fallback or output overflow is attempted" + then: "Execution refuses or remains non-success" + - id: SCN-candidate-fence-closure + given: "A private PID1 namespace observation" + when: "Descendant closure is absent or uncertain" + then: "The outcome is UNKNOWN/INCOMPLETE, never terminal PASS" + observable_outcomes: + - "No DB, ControlPlane, writer, receipt adapter, or authority FD reaches the child" + - "Product input links and special nodes refuse; frozen product S stays under 64MiB/file and 512MiB total" + - "Artifact output remains M/artifactManifest; receipt-adapter A is absent and unclaimed" + determinism_controls: + - "Temporary source trees, fixed Node executable, explicit empty ambient environment, bounded streams, and injected observations" + - "No live policy registration, installation, network, cache, or provider process" + anti_cheat_rationale: + prevents: + - "Reading mutated live source instead of captured bytes" + - "Replaying a private admission or relabeling fixture output as a receipt" + - "Treating PGID emptiness, zero exit, or Chromium metadata as descendant/binary proof" + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + # Console attention advisory cohorts (2026-10-01) ```yaml @@ -2932,6 +2971,47 @@ test_integrity_note: if_true_explain_spec_basis: "" ``` +# Candidate source B2 authenticated-attempt spec correction (2026-10-01) + +```yaml +test_integrity_note: + change_type: spec_correction + scenarios: + - id: SCN-candidate-bootstrap-one-shot + given: "A CheckService-issued opaque registered-plan handle" + when: "A foreign, cloned, expired, replayed, or already-closed handle is used" + then: "No FD3 admission opens; the only accepted acknowledgement is the exact FD4 binding frame and it closes the original linear handle" + - id: SCN-candidate-input-consumption + given: "A frozen, bounded product snapshot and a fixed Node E binding" + when: "The original product bytes change before execution" + then: "An actual spawned child reads the frozen bytes; post-capture drift refuses before spawn" + - id: SCN-candidate-execution-boundary + given: "An opaque attempt with fixed command, arguments, explicit environment, and output cap" + when: "The child exits zero after exceeding the cap" + then: "The parent records overflow as non-success without accepting an injected executor or caller-selected limit" + - id: SCN-candidate-fence-closure + given: "A closed opaque attempt" + when: "The backend has not recorded namespace identity, FD closure, and descendant reaping" + then: "Terminal proof is UNKNOWN; fabricated observations and cloned handles cannot create proof" + observable_outcomes: + - "Only the exact FD4 acknowledgement closes a one-shot opaque admission" + - "Child stdout proves frozen-byte consumption, not same-process test reads" + - "Zero exit plus output overflow is never success" + - "Missing backend fence evidence is never TerminalProof" + determinism_controls: + - "Temporary source trees, fixed absolute Node runtime, explicit empty child environment, bounded streams" + - "No injected executor, caller-provided command, root, environment, limit, or terminal observation" + anti_cheat_rationale: + prevents: + - "Arbitrary tuple issuance or a fabricated bootstrap literal" + - "Boolean caller observations minting terminal proof" + - "Cloned or reused handles reopening an admission" + - "Same-process snapshot reads impersonating child consumption" + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + # Compact Console Overview density (2026-10-01) ```yaml @@ -3064,6 +3144,108 @@ test_integrity_note: if_true_explain_spec_basis: "" ``` +# Exact-source adoption (2026-10-03) + +```yaml +test_integrity_note: + change_type: new_tests + scenarios: + - id: SCN-integration-adoption-private-ref-cas + given: "A clean disposable Git repository, a base commit, and an owner-bound private-ref adoption" + when: "The adoption finalizes and a stale compare-and-swap attempts to alter its candidate ref" + then: "Real Git private archive and candidate refs are created atomically, while the stale write refuses" + - id: SCN-integration-adoption-journal-and-binding + given: "A prepared adoption journal record" + when: "A second preparation or lifecycle request supplies a different candidate binding" + then: "The journal revision compare-and-swap and immutable binding checks refuse without changing the prepared record" + - id: SCN-integration-adoption-finalize-recovery + given: "A real private-ref transaction completed before its journal write" + when: "Reconciliation is run and then replayed" + then: "One durable finalized recovery is recorded and the replay is consistent without another mutation" + - id: SCN-integration-adoption-rollback-recovery + given: "A finalized candidate whose private ref was removed before its rollback journal write, or whose candidate ref diverged" + when: "Rollback is requested" + then: "The completed rollback is recovered once and divergent candidate bytes refuse" + - id: SCN-integration-adoption-private-ref-intake + given: "An existing unfinalized private candidate ref and a separate finalized owner-bound private candidate ref" + when: "Direct private-ref intake and authenticated native qualification are requested" + then: "Direct intake refuses without a queue record, while the authenticated path records the exact private ref and commit in ordinary testing state" + - id: SCN-integration-adoption-approval-boundaries + given: "An ordinary source-only approval, or an adoption whose actor, action, approval revision, or provenance differs from the approved immutable binding" + when: "Preparation or a lifecycle transition is attempted" + then: "The transition refuses with its structured boundary code and no integration queue record" + - id: SCN-integration-adoption-request-replay + given: "A finalized adoption that has created its one ordinary private-ref integration request" + when: "Two independently opened authenticated API callers request it concurrently" + then: "The existing transactional adoption journal serializes them, one caller refuses after the completed transition, and exactly one queue row remains" + - id: SCN-integration-adoption-legacy-retries + given: "An existing project database containing two historical ordinary-request retry rows with the same source branch and commit" + when: "IntegrationService starts" + then: "Both records remain readable; startup adds no global uniqueness constraint and does not reject historical evidence" + observable_outcomes: + - "Git ref values, structured refusal codes, durable journal state, and native integration-request fields" + determinism_controls: + - "Disposable local Git repositories, temporary SQLite-backed control-plane state, fixed fixture bytes, and no network or provider" + anti_cheat_rationale: + prevents: + - "Fixture-only CAS implementations that do not exercise Git update-ref" + - "Mutable lifecycle bindings or overwriteable adoption journal records" + - "Claiming crash recovery without preserving actual ref effects" + - "Treating a private candidate as an ordinary specialist branch" + - "Using an unrelated, source-only, stale, or mismatched approval as adoption authority" + - "Duplicating a private-ref queue record through concurrent authenticated API requests or replay" + - "Rejecting historical ordinary-request retry evidence with an unqualified schema constraint" + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +```yaml +test_integrity_note: + change_type: new_tests + scenarios: + - id: SCN-integration-adoption-lifecycle-revision-cas + given: "A prepared or finalized owner-bound adoption record with a known journal revision" + when: "A caller supplies a stale or omitted expectedRecordRevision to finalize, reconcile, rollback, or request native qualification" + then: "The operation refuses before private-ref, journal, queue, or audit effects; the original record and refs remain unchanged" + observable_outcomes: + - "ADOPTION_RECORD_REVISION_STALE is returned for stale or omitted lifecycle callers" + - "Private candidate/archive refs, durable journal record, audit count, and integration queue remain unchanged for every rejected lifecycle call" + determinism_controls: + - "Disposable local Git repository and temporary SQLite-backed control-plane fixture; no network, provider, or live adoption" + anti_cheat_rationale: + prevents: + - "Issuing external ref or queue effects from a stale lifecycle read" + - "Treating journal revision as an internal-only implementation detail" + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +```yaml +test_integrity_note: + change_type: spec_correction + scenarios: + - id: SCN-integration-adoption-request-replay + given: "Two authenticated callers carry the same finalized adoption revision into the serialized native-request transition" + when: "One caller completes the transition and the other resumes with its now-stale expectedRecordRevision" + then: "The second caller strictly receives ADOPTION_RECORD_REVISION_STALE while exactly one requested journal record and one queue row remain" + observable_outcomes: + - "The replay loser reports the stale journal-revision boundary, not a generic post-state refusal" + - "Exactly one private-ref integration request and requested audit transition exist" + spec_correction_basis: + - "The former ADOPTION_REQUEST_STATE_INVALID replay expectation was wrong: after the serialized winner commits, the loser carries an obsolete journal revision, so revision CAS is the first observable refusal boundary under SCN-integration-adoption-request-replay." + determinism_controls: + - "Two independently opened local API processes over disposable Git and SQLite fixture state" + anti_cheat_rationale: + prevents: + - "Accepting a stale lifecycle read after another caller has committed" + - "Relaxing the exactly-one-request invariant after adding revision binding" + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + ## 2026-10-03 — Schema-v2 read-only owner-reply boundary ```yaml diff --git a/src/integration/adoption.mjs b/src/integration/adoption.mjs new file mode 100644 index 0000000..4d2c7d5 --- /dev/null +++ b/src/integration/adoption.mjs @@ -0,0 +1,384 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { TorchError } from '../kernel/errors.mjs'; +import { observeRuntimeTurn } from '../runtime/turn-guard.mjs'; + +const ADOPTION_SCHEMA = 'torch.dev/integration-adoption/v1alpha1'; +const AUTHORITY_SCHEMA = 'torch.dev/integration-adoption-authority/v1alpha1'; +const ADAPTER_NAME = 'git-private-ref-cas-v1'; +const ACTIONS = new Set(['plan', 'prepare', 'finalize', 'reconcile', 'rollback', 'request']); +const MUTATING_ACTIONS = new Set(['prepare', 'finalize', 'rollback', 'request']); +const SHA = /^[0-9a-f]{40,64}$/; +const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,119}$/; +const ZERO_SHA = '0'.repeat(40); + +function git(root, args, { allowFailure = false } = {}) { + const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' }); + if (result.status !== 0 && !allowFailure) { + throw new TorchError(`Git command failed: git ${args.join(' ')}`, { + code: 'GIT_COMMAND_FAILED', details: result.stderr?.trim() || null, + }); + } + return { status: result.status, stdout: result.stdout?.trim() ?? '', stderr: result.stderr?.trim() ?? '' }; +} + +function tableExists(database, name) { + return Boolean(database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)); +} + +function text(value, name) { + if (typeof value !== 'string' || !value.trim()) { + throw new TorchError(`${name} is required`, { code: 'ADOPTION_INPUT_INVALID', details: { name } }); + } + return value.trim(); +} + +function sha(value, name) { + const normalized = text(value, name); + if (!SHA.test(normalized)) throw new TorchError(`${name} must be a full Git object id`, { + code: 'ADOPTION_INPUT_INVALID', details: { name }, + }); + return normalized; +} + +function digest(value) { + return createHash('sha256').update(JSON.stringify(value)).digest('hex'); +} + +function durableRecord(event) { + if (!event?.details) return null; + try { + const record = JSON.parse(event.details); + return record?.schema === ADOPTION_SCHEMA ? record : null; + } catch { return null; } +} + +function listWorktreePaths(root) { + const records = []; + for (const line of git(root, ['worktree', 'list', '--porcelain']).stdout.split('\n')) { + if (line.startsWith('worktree ')) records.push(line.slice('worktree '.length)); + } + return records; +} + +function authorityFrom(approval) { + if (!approval || approval.status !== 'approved' || approval.approver_id !== 'owner' || approval.decided_by !== 'owner') return null; + try { + const authority = JSON.parse(approval.decision_note ?? ''); + return authority?.schema === AUTHORITY_SCHEMA ? authority : null; + } catch { return null; } +} + +function exactAuthority(authority, binding) { + if (!authority || authority.action !== binding.action) return false; + const fields = ['adoptionId', 'candidateSha', 'baseTargetSha', 'provenanceManifestDigest', + 'contributorArea', 'destinationArea', 'targetBranch', 'candidateRef', 'expectedOldRef']; + return fields.every((field) => authority[field] === binding[field]) + && authority.adapter?.name === binding.adapter?.name + && authority.adapter?.id === binding.adapter?.id; +} + +function exactInput(record, input) { + const fields = ['adoptionId', 'candidateSha', 'baseTargetSha', 'provenanceManifestDigest', + 'contributorArea', 'destinationArea', 'targetBranch', 'candidateRef', 'expectedOldRef']; + const mismatches = fields.filter((field) => input[field] !== undefined + && input[field] !== (field === 'adoptionId' ? record.id : record[field])); + if (input.provenance !== undefined && JSON.stringify(input.provenance) !== JSON.stringify(record.provenance)) mismatches.push('provenance'); + if (mismatches.length) throw new TorchError('Lifecycle input cannot override immutable prepared adoption fields', { + code: 'ADOPTION_IMMUTABLE_BINDING_STALE', details: { adoptionId: record.id, fields: mismatches }, + }); + if (!Number.isInteger(input.expectedRecordRevision) || input.expectedRecordRevision !== record.revision) { + throw new TorchError('Lifecycle input must bind the current adoption record revision', { + code: 'ADOPTION_RECORD_REVISION_STALE', + details: { adoptionId: record.id, expectedRevision: input.expectedRecordRevision ?? null, actual: record.revision }, + }); + } +} + +/** + * Real Git private-ref transaction adapter. It is source code only: callers + * decide when it is invoked, while the adapter itself gives temp-repository + * scenarios the same atomic update-ref semantics as a real repository. + */ +export class GitPrivateRefAdapter { + constructor({ repositoryRoot } = {}) { + this.repositoryRoot = resolve(text(repositoryRoot, 'repositoryRoot')); + this.name = ADAPTER_NAME; + this.id = digest({ adapter: this.name, repositoryRoot: this.repositoryRoot }); + } + + readRefs(refs) { + const values = {}; + for (const ref of refs) { + const result = git(this.repositoryRoot, ['rev-parse', '--verify', '--quiet', ref], { allowFailure: true }); + values[ref] = result.status === 0 ? result.stdout : null; + } + return values; + } + + compareAndSwapRefs({ expected = [], writes = [] } = {}) { + const commands = ['start']; + const writtenRefs = new Set(writes.map((entry) => entry.ref)); + for (const { ref, sha: expectedSha } of expected) { + if (writtenRefs.has(ref)) continue; + commands.push(`verify ${text(ref, 'ref')} ${expectedSha ?? ZERO_SHA}`); + } + for (const { ref, sha: nextSha } of writes) { + const expectedEntry = expected.find((entry) => entry.ref === ref); + if (!expectedEntry) throw new TorchError('Every private-ref write requires an exact expected value', { + code: 'ADOPTION_REF_CAS_EXPECTED_REQUIRED', details: { ref }, + }); + const current = expectedEntry.sha ?? null; + if (nextSha === null) commands.push(`delete ${text(ref, 'ref')} ${current ?? ZERO_SHA}`); + else commands.push(`update ${text(ref, 'ref')} ${sha(nextSha, 'next ref sha')} ${current ?? ZERO_SHA}`); + } + commands.push('prepare', 'commit', ''); + const result = spawnSync('git', ['-C', this.repositoryRoot, 'update-ref', '--stdin'], { + input: commands.join('\n'), encoding: 'utf8', + }); + if (result.status !== 0) throw new TorchError('Private ref compare-and-swap refused', { + code: 'ADOPTION_REF_CAS_FAILED', details: result.stderr?.trim() || null, + }); + return { adapter: { name: this.name, id: this.id }, expected, writes }; + } +} + +export class AdoptionService { + constructor({ repositoryRoot, controlPlane, clock = () => new Date(), idFactory = randomUUID, policy, refAdapter } = {}) { + this.repositoryRoot = repositoryRoot; + this.controlPlane = controlPlane; + this.clock = clock; + this.idFactory = idFactory; + this.policy = policy ?? {}; + this.refAdapter = refAdapter ?? new GitPrivateRefAdapter({ repositoryRoot }); + } + + plan({ action = 'plan', actorId, approvalId, approvalRevision, adoptionId, + candidateSha, baseTargetSha, provenance, provenanceManifestDigest, contributorArea, + destinationArea, targetBranch, candidateRef, expectedOldRef, adapter } = {}) { + const blockers = []; + const id = adoptionId ? text(adoptionId, 'adoptionId') : null; + if (id && !SAFE_ID.test(id)) blockers.push({ code: 'ADOPTION_ID_INVALID' }); + if (!ACTIONS.has(action)) blockers.push({ code: 'ADOPTION_ACTION_INVALID' }); + if (actorId !== 'owner') blockers.push({ code: 'OWNER_ACTOR_REQUIRED' }); + if (!Number.isInteger(approvalRevision) || approvalRevision < 1) blockers.push({ code: 'ADOPTION_APPROVAL_REVISION_REQUIRED' }); + const adapterBinding = adapter?.name && adapter?.id ? { name: adapter.name, id: adapter.id } : null; + if (MUTATING_ACTIONS.has(action) && !adapterBinding) blockers.push({ code: 'ADOPTION_ADAPTER_BINDING_REQUIRED' }); + if (adapterBinding && (adapterBinding.name !== this.refAdapter.name || adapterBinding.id !== this.refAdapter.id)) { + blockers.push({ code: 'ADOPTION_ADAPTER_PROVENANCE_INVALID' }); + } + + let candidate; let base; let provenanceDigest; + try { + candidate = sha(candidateSha, 'candidateSha'); + base = sha(baseTargetSha, 'baseTargetSha'); + if (!Array.isArray(provenance) || !provenance.length || provenance.some((entry) => typeof entry !== 'string' || !entry)) { + throw new TorchError('provenance must be a non-empty ordered string list', { code: 'ADOPTION_INPUT_INVALID' }); + } + provenanceDigest = digest(provenance); + if (sha(provenanceManifestDigest, 'provenanceManifestDigest') !== provenanceDigest) blockers.push({ code: 'ADOPTION_PROVENANCE_DIGEST_MISMATCH', expected: provenanceDigest }); + } catch (error) { + blockers.push({ code: error.code ?? 'ADOPTION_INPUT_INVALID', details: error.details ?? null }); + } + + const target = targetBranch ?? this.policy.target; + if (typeof target !== 'string' || !target.trim()) blockers.push({ code: 'ADOPTION_TARGET_REQUIRED' }); + const candidateReference = candidateRef ?? (id ? `refs/torch/integration-candidates/${id}` : null); + const expectedReference = expectedOldRef ?? (target ? `refs/heads/${target}` : null); + if (!candidateReference || !/^refs\/torch\/integration-candidates\/[A-Za-z0-9._-]+$/.test(candidateReference)) blockers.push({ code: 'ADOPTION_CANDIDATE_REF_INVALID' }); + if (!expectedReference || !/^refs\/heads\/[A-Za-z0-9._/-]+$/.test(expectedReference)) blockers.push({ code: 'ADOPTION_EXPECTED_REF_INVALID' }); + + let contributor; let destination; + try { contributor = this.controlPlane.assertIdentity(text(contributorArea, 'contributorArea')); } + catch (error) { blockers.push({ code: 'ADOPTION_CONTRIBUTOR_INVALID', details: error.code ?? null }); } + try { destination = this.controlPlane.assertIdentity(text(destinationArea, 'destinationArea')); } + catch (error) { blockers.push({ code: 'ADOPTION_DESTINATION_INVALID', details: error.code ?? null }); } + if (contributor && destination && contributor === destination) blockers.push({ code: 'ADOPTION_IDENTITIES_MUST_DIFFER' }); + + const binding = { action, adoptionId: id, candidateSha: candidate, baseTargetSha: base, provenanceManifestDigest: provenanceDigest, + contributorArea: contributor, destinationArea: destination, targetBranch: target, candidateRef: candidateReference, + expectedOldRef: expectedReference, adapter: adapterBinding }; + const approval = approvalId ? this.controlPlane.database.prepare(`SELECT * FROM approval_requests WHERE id = ? AND project_id = ?`) + .get(approvalId, this.controlPlane.projectId) : null; + if (!approval) blockers.push({ code: 'ADOPTION_APPROVAL_MISSING' }); + else if (approval.revision !== approvalRevision) blockers.push({ code: 'ADOPTION_APPROVAL_REVISION_STALE', actual: approval.revision }); + else if (!exactAuthority(authorityFrom(approval), binding)) blockers.push({ code: 'ADOPTION_APPROVAL_BINDING_INVALID' }); + + if (candidate && git(this.repositoryRoot, ['cat-file', '-e', `${candidate}^{commit}`], { allowFailure: true }).status !== 0) blockers.push({ code: 'ADOPTION_CANDIDATE_MISSING' }); + if (base && git(this.repositoryRoot, ['cat-file', '-e', `${base}^{commit}`], { allowFailure: true }).status !== 0) blockers.push({ code: 'ADOPTION_BASE_MISSING' }); + if (candidate && base && git(this.repositoryRoot, ['merge-base', '--is-ancestor', base, candidate], { allowFailure: true }).status !== 0) blockers.push({ code: 'ADOPTION_CANDIDATE_NOT_DESCENDED_FROM_BASE' }); + if (expectedReference && base) { + const current = this.refAdapter.readRefs([expectedReference])[expectedReference]; + if (!current) blockers.push({ code: 'ADOPTION_EXPECTED_REF_MISSING' }); + else if (current !== base) blockers.push({ code: 'ADOPTION_EXPECTED_REF_STALE', expected: base, actual: current }); + } + const candidateExists = candidateReference && this.refAdapter.readRefs([candidateReference])[candidateReference] != null; + if (['plan', 'prepare', 'finalize'].includes(action) && candidateExists) blockers.push({ code: 'ADOPTION_CANDIDATE_REF_ALREADY_EXISTS' }); + if (action === 'request' && !candidateExists) blockers.push({ code: 'ADOPTION_CANDIDATE_REF_MISSING' }); + + for (const worktree of listWorktreePaths(this.repositoryRoot)) { + const entries = git(worktree, ['status', '--porcelain=v1', '--untracked-files=all']).stdout.split('\n').filter(Boolean); + if (entries.length) blockers.push({ code: 'ADOPTION_WORKTREE_DIRTY', worktree, entries }); + } + if (tableExists(this.controlPlane.database, 'worktree_guards')) { + for (const guard of this.controlPlane.database.prepare(`SELECT area_id, guard_type, reason FROM worktree_guards WHERE project_id = ? AND released_at IS NULL`).all(this.controlPlane.projectId)) blockers.push({ code: 'ADOPTION_WORKTREE_GUARD_ACTIVE', guard }); + } + if (tableExists(this.controlPlane.database, 'resource_leases')) { + for (const lease of this.controlPlane.database.prepare(`SELECT id, resource_id, area_id FROM resource_leases WHERE released_at IS NULL`).all()) blockers.push({ code: 'ADOPTION_RESOURCE_LEASE_ACTIVE', lease }); + } + if (tableExists(this.controlPlane.database, 'prepared_checks')) { + for (const check of this.controlPlane.database.prepare(`SELECT id, area_id, state FROM prepared_checks WHERE project_id = ? AND state IN ('prepared', 'running')`).all(this.controlPlane.projectId)) blockers.push({ code: 'ADOPTION_PREPARED_CHECK_ACTIVE', check }); + } + for (const area of [contributor, destination].filter(Boolean)) { + const executor = observeRuntimeTurn({ stateRoot: this.controlPlane.stateRoot, repositoryRoot: this.repositoryRoot, + projectId: this.controlPlane.projectId, areaId: area, now: this.clock }); + if (executor.state !== 'inactive') blockers.push({ code: 'ADOPTION_EXECUTOR_NOT_INACTIVE', areaId: area, executor }); + } + return { action: `plan-adoption-${action}`, adoptionId: id, approval: approval ? { id: approval.id, revision: approval.revision, status: approval.status, approver: approval.approver_id } : null, + authority: binding, candidateSha: candidate ?? null, baseTargetSha: base ?? null, provenanceManifestDigest: provenanceDigest ?? null, + contributorArea: contributor ?? null, destinationArea: destination ?? null, targetBranch: target ?? null, candidateRef: candidateReference, + expectedOldRef: expectedReference, blockers, canProceed: blockers.length === 0, mutationPerformed: false }; + } + + #append(adoptionId, expectedRevision, record, operation) { + this.controlPlane.database.exec('BEGIN IMMEDIATE'); + try { + this.#appendInTransaction(adoptionId, expectedRevision, record, operation); + this.controlPlane.database.exec('COMMIT'); + } catch (error) { this.controlPlane.database.exec('ROLLBACK'); throw error; } + } + + #appendInTransaction(adoptionId, expectedRevision, record, operation) { + const current = this.#find(adoptionId); + if (expectedRevision === null ? current : !current || current.revision !== expectedRevision) { + throw new TorchError('Adoption journal changed before this transition could be recorded', { + code: 'ADOPTION_RECORD_REVISION_STALE', details: { adoptionId, expectedRevision, actual: current?.revision ?? null }, + }); + } + this.controlPlane.database.prepare(`INSERT INTO audit_events + (id, project_id, actor_id, operation, entity_type, entity_id, details, created_at) + VALUES (?, ?, 'owner', ?, 'integration-adoption', ?, ?, ?)`) + .run(this.idFactory(), this.controlPlane.projectId, operation, adoptionId, JSON.stringify(record), this.clock().toISOString()); + } + + #fromRecord(record, input, action) { + exactInput(record, input); + return this.plan({ action, actorId: input.actorId, approvalId: input.approvalId, approvalRevision: input.approvalRevision, + adoptionId: record.id, candidateSha: record.candidateSha, baseTargetSha: record.baseTargetSha, provenance: record.provenance, + provenanceManifestDigest: record.provenanceManifestDigest, contributorArea: record.contributorArea, destinationArea: record.destinationArea, + targetBranch: record.targetBranch, candidateRef: record.candidateRef, expectedOldRef: record.expectedOldRef, adapter: input.adapter }); + } + + prepare(input = {}) { + const adoptionId = input.adoptionId ?? this.idFactory(); + const plan = this.plan({ ...input, action: 'prepare', adoptionId }); + if (!plan.canProceed) throw new TorchError('Adoption inputs are not eligible for preparation', { code: 'ADOPTION_PREPARE_BLOCKED', details: plan.blockers }); + const now = this.clock().toISOString(); + const record = { schema: ADOPTION_SCHEMA, id: adoptionId, revision: 1, state: 'prepared', approvalId: text(input.approvalId, 'approvalId'), + approvalRevision: input.approvalRevision, authority: plan.authority, provenance: [...input.provenance], candidateSha: plan.candidateSha, + baseTargetSha: plan.baseTargetSha, provenanceManifestDigest: plan.provenanceManifestDigest, candidateRef: plan.candidateRef, + expectedOldRef: plan.expectedOldRef, contributorArea: plan.contributorArea, destinationArea: plan.destinationArea, targetBranch: plan.targetBranch, + archiveRef: `refs/torch/integration-archives/${adoptionId}`, preparedAt: now, finalizedAt: null, rolledBackAt: null }; + this.#append(adoptionId, null, record, 'integration.adoption.prepare'); + return { ...record, mutationPerformed: true }; + } + + finalize(input = {}) { + const record = this.getPrepared(text(input.adoptionId, 'adoptionId')); + if (record.state !== 'prepared') throw new TorchError('Only a prepared adoption can be finalized', { code: 'ADOPTION_FINALIZE_STATE_INVALID', details: { state: record.state } }); + const plan = this.#fromRecord(record, input, 'finalize'); + if (!plan.canProceed) throw new TorchError('Adoption finalize is blocked', { code: 'ADOPTION_FINALIZE_BLOCKED', details: plan.blockers }); + const receipt = this.refAdapter.compareAndSwapRefs({ expected: [{ ref: record.expectedOldRef, sha: record.baseTargetSha }, { ref: record.candidateRef, sha: null }, { ref: record.archiveRef, sha: null }], + writes: [{ ref: record.archiveRef, sha: record.baseTargetSha }, { ref: record.candidateRef, sha: record.candidateSha }] }); + const finalized = { ...record, revision: record.revision + 1, state: 'finalized', finalizeApprovalId: text(input.approvalId, 'approvalId'), + finalizeApprovalRevision: input.approvalRevision, finalizedAt: this.clock().toISOString(), refReceiptDigest: digest(receipt) }; + this.#append(record.id, record.revision, finalized, 'integration.adoption.finalize'); + return { ...finalized, mutationPerformed: true }; + } + + reconcile(input = {}) { + const record = this.getPrepared(text(input.adoptionId, 'adoptionId')); + const plan = this.#fromRecord(record, input, 'reconcile'); + if (!plan.canProceed) return { record, plan, state: 'blocked', mutationPerformed: false }; + const refs = this.refAdapter.readRefs([record.expectedOldRef, record.candidateRef, record.archiveRef]); + const finalizedRefs = refs[record.expectedOldRef] === record.baseTargetSha && refs[record.candidateRef] === record.candidateSha && refs[record.archiveRef] === record.baseTargetSha; + if (record.state === 'prepared' && finalizedRefs) { + const recovered = { ...record, revision: record.revision + 1, state: 'finalized', finalizedAt: this.clock().toISOString(), + recoveredAt: this.clock().toISOString(), recovery: 'private-ref-cas-completed-before-journal' }; + this.#append(record.id, record.revision, recovered, 'integration.adoption.reconcile-finalized'); + return { record: recovered, refs, state: 'recovered-finalized', mutationPerformed: true }; + } + if (record.state === 'finalized' && finalizedRefs) return { record, refs, state: 'consistent', mutationPerformed: false }; + if (record.state === 'prepared' && refs[record.candidateRef] === null && refs[record.archiveRef] === null) return { record, refs, state: 'prepared', mutationPerformed: false }; + return { record, refs, state: 'diverged', mutationPerformed: false }; + } + + rollback(input = {}) { + const record = this.getPrepared(text(input.adoptionId, 'adoptionId')); + if (record.state !== 'finalized') throw new TorchError('Only a finalized adoption can be rolled back', { code: 'ADOPTION_ROLLBACK_STATE_INVALID', details: { state: record.state } }); + const plan = this.#fromRecord(record, input, 'rollback'); + if (!plan.canProceed) throw new TorchError('Adoption rollback is blocked', { code: 'ADOPTION_ROLLBACK_BLOCKED', details: plan.blockers }); + const refs = this.refAdapter.readRefs([record.expectedOldRef, record.candidateRef, record.archiveRef]); + const recovered = refs[record.expectedOldRef] === record.baseTargetSha + && refs[record.candidateRef] === null && refs[record.archiveRef] === record.baseTargetSha; + if (!recovered && refs[record.candidateRef] !== record.candidateSha) { + throw new TorchError('Candidate ref changed before rollback', { code: 'ADOPTION_ROLLBACK_REF_DIVERGED', details: refs }); + } + const receipt = recovered ? { adapter: { name: this.refAdapter.name, id: this.refAdapter.id }, recovered: true, refs } + : this.refAdapter.compareAndSwapRefs({ expected: [{ ref: record.candidateRef, sha: record.candidateSha }], writes: [{ ref: record.candidateRef, sha: null }] }); + const rolledBack = { ...record, revision: record.revision + 1, state: 'rolled-back', rollbackApprovalId: text(input.approvalId, 'approvalId'), + rollbackApprovalRevision: input.approvalRevision, rolledBackAt: this.clock().toISOString(), rollbackReceiptDigest: digest(receipt), + ...(recovered ? { recoveredAt: this.clock().toISOString(), recovery: 'private-ref-rollback-completed-before-journal' } : {}) }; + this.#append(record.id, record.revision, rolledBack, 'integration.adoption.rollback'); + return { ...rolledBack, mutationPerformed: true }; + } + + requestAdoptedCandidate(input = {}, { requestIntegration, findIntegrationRequest } = {}) { + if (typeof requestIntegration !== 'function') throw new TorchError('Native integration request adapter is unavailable', { code: 'ADOPTION_NATIVE_REQUEST_UNAVAILABLE' }); + const adoptionId = text(input.adoptionId, 'adoptionId'); + this.controlPlane.database.exec('BEGIN IMMEDIATE'); + try { + const record = this.getPrepared(adoptionId); + exactInput(record, input); + if (!['finalized', 'requesting'].includes(record.state)) { + throw new TorchError('Only a finalized adoption can request native qualification', { + code: 'ADOPTION_REQUEST_STATE_INVALID', details: { state: record.state }, + }); + } + const plan = this.#fromRecord(record, input, 'request'); + if (!plan.canProceed) throw new TorchError('Adopted candidate request is blocked', { code: 'ADOPTION_REQUEST_BLOCKED', details: plan.blockers }); + const requesting = record.state === 'requesting' ? record : { ...record, revision: record.revision + 1, state: 'requesting', + requestApprovalId: text(input.approvalId, 'approvalId'), requestApprovalRevision: input.approvalRevision, requestStartedAt: this.clock().toISOString() }; + if (record.state !== 'requesting') this.#appendInTransaction(record.id, record.revision, requesting, 'integration.adoption.request-started'); + const existing = findIntegrationRequest?.({ candidateRef: requesting.candidateRef, commit: requesting.candidateSha }); + if (existing && (existing.sourceArea !== requesting.contributorArea || existing.targetBranch !== requesting.targetBranch)) { + throw new TorchError('Existing private-ref integration request does not match this adoption', { + code: 'ADOPTION_INTEGRATION_REQUEST_CONFLICT', details: { adoptionId: requesting.id, integrationRequestId: existing.id }, + }); + } + const request = existing ?? requestIntegration({ adoptionId: requesting.id, areaId: requesting.contributorArea, + commit: requesting.candidateSha, candidateRef: requesting.candidateRef }); + const requested = { ...requesting, revision: requesting.revision + 1, state: 'requested', integrationRequestId: request.id, requestedAt: this.clock().toISOString() }; + this.#appendInTransaction(requesting.id, requesting.revision, requested, 'integration.adoption.request'); + this.controlPlane.database.exec('COMMIT'); + return { ...requested, integrationRequest: request, mutationPerformed: true }; + } catch (error) { + this.controlPlane.database.exec('ROLLBACK'); + throw error; + } + } + + #find(adoptionId) { + const row = this.controlPlane.database.prepare(`SELECT details FROM audit_events WHERE project_id = ? + AND entity_type = 'integration-adoption' AND entity_id = ? ORDER BY rowid DESC LIMIT 1`).get(this.controlPlane.projectId, adoptionId); + return durableRecord(row); + } + + getPrepared(adoptionId) { + const id = text(adoptionId, 'adoptionId'); + const record = this.#find(id); + if (!record) throw new TorchError('Unknown adoption journal record', { code: 'ADOPTION_NOT_FOUND', details: { adoptionId: id } }); + return record; + } +} diff --git a/src/integration/service.mjs b/src/integration/service.mjs index c996634..e027dab 100644 --- a/src/integration/service.mjs +++ b/src/integration/service.mjs @@ -4,6 +4,7 @@ import { TorchError } from '../kernel/errors.mjs'; import { readInstallManifest } from '../kernel/install.mjs'; import { loadFleetDefinition } from '../kernel/worktrees.mjs'; import { BacklogService } from '../backlog/service.mjs'; +import { AdoptionService } from './adoption.mjs'; function git(root, args, { allowFailure = false } = {}) { const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' }); @@ -15,6 +16,21 @@ function git(root, args, { allowFailure = false } = {}) { return { status: result.status, stdout: result.stdout?.trim() ?? '', stderr: result.stderr?.trim() ?? '' }; } +function text(value, name) { + if (typeof value !== 'string' || !value.trim()) { + throw new TorchError(`${name} is required`, { code: 'INTEGRATION_INPUT_INVALID', details: { name } }); + } + return value.trim(); +} + +function sha(value, name) { + const normalized = text(value, name); + if (!/^[0-9a-f]{40,64}$/.test(normalized)) { + throw new TorchError(`${name} must be a full Git object id`, { code: 'INTEGRATION_INPUT_INVALID', details: { name } }); + } + return normalized; +} + function integrationRow(row) { if (!row) return null; return { @@ -42,6 +58,8 @@ export class IntegrationService { this.worktrees = new Map((manifest.external ?? []) .filter((entry) => entry.type === 'worktree') .map((entry) => [entry.area, entry])); + this.adoption = new AdoptionService({ repositoryRoot, controlPlane, clock, idFactory, + policy: { target: this.policy.target } }); this.controlPlane.database.exec(` CREATE TABLE IF NOT EXISTS integration_requests ( id TEXT PRIMARY KEY, @@ -84,12 +102,56 @@ export class IntegrationService { code: 'INTEGRATION_SOURCE_MOVED', details: { branchTip, sourceCommit }, }); } + return this.#registerRequest({ area, sourceBranch: worktree.branch, sourceCommit }); + } + + requestCandidate({ areaId, commit, candidateRef } = {}) { + throw new TorchError('Private candidate intake requires an authenticated finalized adoption', { + code: 'INTEGRATION_ADOPTED_INTAKE_INTERNAL_ONLY', details: { areaId, commit, candidateRef }, + }); + } + + #requestFinalizedCandidate({ adoptionId, areaId, commit, candidateRef } = {}) { + const area = this.controlPlane.assertIdentity(areaId); + if (area === 'session-manager') { + throw new TorchError('Session Manager does not own a feature worktree by default', { + code: 'INTEGRATION_SOURCE_INVALID', + }); + } + const adoption = this.adoption.getPrepared(text(adoptionId, 'adoptionId')); + if (adoption.state !== 'requesting' || adoption.contributorArea !== area + || adoption.candidateSha !== commit || adoption.candidateRef !== candidateRef) { + throw new TorchError('Private candidate does not match an authenticated finalized adoption', { + code: 'INTEGRATION_ADOPTED_BINDING_INVALID', details: { adoptionId, state: adoption.state }, + }); + } + const ref = text(candidateRef, 'candidateRef'); + if (!/^refs\/torch\/integration-candidates\/[A-Za-z0-9._-]+$/.test(ref)) { + throw new TorchError('Adopted candidates must use a private integration candidate ref', { + code: 'INTEGRATION_ADOPTED_REF_INVALID', details: { candidateRef: ref }, + }); + } + const sourceCommit = sha(commit, 'commit'); + const refTip = git(this.repositoryRoot, ['rev-parse', '--verify', ref], { allowFailure: true }); + if (refTip.status !== 0 || refTip.stdout !== sourceCommit) { + throw new TorchError('Adopted candidate ref no longer names the exact requested commit', { + code: 'INTEGRATION_ADOPTED_REF_STALE', details: { candidateRef: ref, sourceCommit, actual: refTip.stdout || null }, + }); + } + const existing = this.controlPlane.database.prepare(`SELECT id FROM integration_requests + WHERE project_id = ? AND source_branch = ? AND source_commit = ? ORDER BY rowid LIMIT 1`) + .get(this.controlPlane.projectId, ref, sourceCommit); + if (existing) return this.get(existing.id); + return this.#registerRequest({ area, sourceBranch: ref, sourceCommit }); + } + + #registerRequest({ area, sourceBranch, sourceCommit }) { const targetBranch = this.policy.target; const baseTargetCommit = git(this.repositoryRoot, ['rev-parse', targetBranch]).stdout; const now = this.clock().toISOString(); const record = { id: this.idFactory(), projectId: this.controlPlane.projectId, sourceArea: area, - sourceBranch: worktree.branch, sourceCommit, targetBranch, baseTargetCommit, + sourceBranch, sourceCommit, targetBranch, baseTargetCommit, state: 'testing', reason: null, requiredChecks: this.policy.required_checks ?? [], authorizedBy: null, authorizedAt: null, createdAt: now, updatedAt: now, landedAt: null, }; @@ -110,6 +172,44 @@ export class IntegrationService { return this.evaluate(record.id); } + planAdoption(input = {}) { + return this.adoption.plan(input); + } + + prepareAdoption(input = {}) { + return this.adoption.prepare(input); + } + + getPreparedAdoption(adoptionId) { + return this.adoption.getPrepared(adoptionId); + } + + finalizeAdoption(input = {}) { + return this.adoption.finalize(input); + } + + reconcileAdoption(input = {}) { + return this.adoption.reconcile(input); + } + + rollbackAdoption(input = {}) { + return this.adoption.rollback(input); + } + + requestAdoptedCandidate(input = {}) { + return this.adoption.requestAdoptedCandidate(input, { + requestIntegration: ({ adoptionId, areaId, commit, candidateRef }) => this.#requestFinalizedCandidate({ adoptionId, areaId, commit, candidateRef }), + findIntegrationRequest: ({ candidateRef, commit }) => this.findCandidateRequest({ candidateRef, commit }), + }); + } + + findCandidateRequest({ candidateRef, commit } = {}) { + const row = this.controlPlane.database.prepare(`SELECT id FROM integration_requests + WHERE project_id = ? AND source_branch = ? AND source_commit = ? ORDER BY rowid LIMIT 1`) + .get(this.controlPlane.projectId, candidateRef, commit); + return row ? this.get(row.id) : null; + } + get(requestId) { const row = this.controlPlane.database.prepare('SELECT * FROM integration_requests WHERE id = ?').get(requestId); if (!row) throw new TorchError(`Unknown integration request: ${requestId}`, { code: 'INTEGRATION_NOT_FOUND' }); diff --git a/test/fleet/integration-adoption.test.mjs b/test/fleet/integration-adoption.test.mjs new file mode 100644 index 0000000..3ec6641 --- /dev/null +++ b/test/fleet/integration-adoption.test.mjs @@ -0,0 +1,420 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { execFileSync, spawn } from 'node:child_process'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import test from 'node:test'; +import { openControlPlane } from '../../src/control-plane/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 { IntegrationService } from '../../src/integration/service.mjs'; + +const sha = value => execFileSync('git', ['-C', value, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); +const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex'); + +function fixture(name) { + const root = mkdtempSync(join(tmpdir(), `${name}-`)); + 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, scripts: {} })}\n`); + writeFileSync(join(root, 'app.mjs'), 'export const value = 1;\n'); + execFileSync('git', ['-C', root, 'add', '.']); + execFileSync('git', ['-C', root, 'commit', '-m', 'fixture base']); + const repository = inspectRepository(root); + const proposal = proposeDomains({ repository, analysis: analyzeRepository(repository) }); + proposal.domains.push({ + id: 'review-area', title: 'Review Area', kind: 'development', + scope: ['review fixtures'], not_scope: ['application source'], owned_paths: ['review/**'], + shared_paths: [], neighbours: [], required_checks: [], resources: [], runtime: 'claude', evidence: ['app.mjs'], + }); + proposal.checks = []; + proposal.review = { status: 'approved', reviewedAt: '2026-10-03T00:00:00.000Z', reviewedBy: 'fixture-owner', notes: [] }; + const env = { ...process.env, XDG_DATA_HOME: join(tmpdir(), `${basename(root)}-state`) }; + installProject({ repository, proposal, env, projectId: name }); + execFileSync('git', ['-C', root, 'add', '.torch']); + execFileSync('git', ['-C', root, 'commit', '-m', 'install fixture control plane']); + const base = sha(root); + execFileSync('git', ['-C', root, 'checkout', '-b', 'candidate']); + writeFileSync(join(root, 'candidate.mjs'), 'export const candidate = true;\n'); + execFileSync('git', ['-C', root, 'add', 'candidate.mjs']); + execFileSync('git', ['-C', root, 'commit', '-m', 'candidate contribution']); + const candidate = sha(root); + execFileSync('git', ['-C', root, 'checkout', 'main']); + const control = openControlPlane({ repositoryRoot: root, env }); + const service = new IntegrationService({ repositoryRoot: root, controlPlane: control, checkService: { exactPasses: () => false } }); + const contributor = proposal.domains[0].id; + return { root, env, control, service, contributor, destination: 'review-area', base, candidate }; +} + +function concurrentAdoptedRequest(context, requestInput) { + const childSource = ` + import { pathToFileURL } from 'node:url'; + import { resolve } from 'node:path'; + const { repositoryRoot, stateRoot, requestInput } = JSON.parse(process.argv[1]); + const sourceRoot = process.cwd(); + const { openControlPlane } = await import(pathToFileURL(resolve(sourceRoot, 'src/control-plane/service.mjs')).href); + const { IntegrationService } = await import(pathToFileURL(resolve(sourceRoot, 'src/integration/service.mjs')).href); + const control = openControlPlane({ repositoryRoot, env: { ...process.env, XDG_DATA_HOME: stateRoot } }); + const service = new IntegrationService({ repositoryRoot, controlPlane: control, checkService: { exactPasses: () => false } }); + try { + const result = service.requestAdoptedCandidate(requestInput); + console.log(JSON.stringify({ outcome: 'requested', integrationRequestId: result.integrationRequest.id })); + } catch (error) { + console.log(JSON.stringify({ outcome: 'refused', code: error.code ?? null })); + } finally { + control.close(); + } + `; + const encoded = JSON.stringify({ repositoryRoot: context.root, stateRoot: context.env.XDG_DATA_HOME, requestInput }); + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['--input-type=module', '-e', childSource, encoded], { + cwd: process.cwd(), env: process.env, stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; let stderr = ''; + child.stdout.on('data', chunk => { stdout += chunk; }); + child.stderr.on('data', chunk => { stderr += chunk; }); + child.on('error', reject); + child.on('close', (code) => { + if (code !== 0) return reject(new Error(`concurrent adopted request child failed: ${stderr}`)); + try { resolve(JSON.parse(stdout.trim().split('\n').at(-1))); } + catch (error) { reject(new Error(`invalid concurrent adopted request output: ${stdout}\n${stderr}\n${error.message}`)); } + }); + }); +} + +function authority(context, { action, adoptionId }) { + const provenance = ['fixture:owner-approved', `candidate:${context.candidate}`]; + const binding = { + schema: 'torch.dev/integration-adoption-authority/v1alpha1', action, adoptionId, + candidateSha: context.candidate, baseTargetSha: context.base, + provenanceManifestDigest: digest(provenance), contributorArea: context.contributor, + destinationArea: context.destination, targetBranch: 'main', + candidateRef: `refs/torch/integration-candidates/${adoptionId}`, + expectedOldRef: 'refs/heads/main', adapter: { + name: context.service.adoption.refAdapter.name, id: context.service.adoption.refAdapter.id, + }, + }; + const pending = context.control.requestApproval({ + requester: context.contributor, approver: 'owner', task: 'TASK-f40126ee', + title: `Fixture ${action} authority`, summary: 'Hermetic exact-source adoption scenario.', + }); + const approved = context.control.decideApproval({ + approvalId: pending.id, decidedBy: 'owner', decision: 'approved', expectedRevision: pending.revision, + note: JSON.stringify(binding), + }); + return { + approvalId: approved.id, approvalRevision: approved.revision, provenance, + provenanceManifestDigest: binding.provenanceManifestDigest, adapter: binding.adapter, + }; +} + +function input(context, action, adoptionId, extra = {}) { + return { + actorId: 'owner', adoptionId, candidateSha: context.candidate, baseTargetSha: context.base, + contributorArea: context.contributor, destinationArea: context.destination, + targetBranch: 'main', candidateRef: `refs/torch/integration-candidates/${adoptionId}`, + expectedOldRef: 'refs/heads/main', ...authority(context, { action, adoptionId }), ...extra, + }; +} + +function prepare(context, adoptionId = 'fixture-adoption') { + const preparedInput = input(context, 'prepare', adoptionId); + const plan = context.service.planAdoption({ ...preparedInput, action: 'prepare' }); + assert.deepEqual(plan.blockers, [], `preparation must begin from an exact clean fixture: ${JSON.stringify(plan.blockers)}`); + return context.service.prepareAdoption(preparedInput); +} + +function lifecycleInput(context, action, adoptionId, extra = {}) { + const { approvalId, approvalRevision, adapter } = authority(context, { action, adoptionId }); + const record = context.service.getPreparedAdoption(adoptionId); + return { actorId: 'owner', adoptionId, expectedRecordRevision: record.revision, approvalId, approvalRevision, adapter, ...extra }; +} + +function blockedCodes(error) { + assert.equal(error.code.endsWith('_BLOCKED'), true, `expected a blocked adoption transition, got ${error.code}`); + return error.details.map((blocker) => blocker.code); +} + +function lifecycleBoundary(context, adoptionId) { + const record = context.service.getPreparedAdoption(adoptionId); + return { + record, + refs: context.service.adoption.refAdapter.readRefs([ + record.expectedOldRef, record.candidateRef, record.archiveRef, + ]), + queue: context.service.list(), + auditCount: context.control.database.prepare(`SELECT COUNT(*) AS count FROM audit_events + WHERE project_id = ? AND entity_type = 'integration-adoption' AND entity_id = ?`) + .get(context.control.projectId, adoptionId).count, + }; +} + +function assertLifecycleBoundaryUnchanged(context, adoptionId, before) { + assert.deepEqual(lifecycleBoundary(context, adoptionId), before); +} + +test('SCN-integration-adoption-private-ref-cas: a real temporary Git transaction atomically creates private archive and candidate refs, and stale expected refs refuse', () => { + const context = fixture('torch-adoption-cas'); + const adoptionId = 'fixture-adoption'; + prepare(context, adoptionId); + const finalized = context.service.finalizeAdoption(lifecycleInput(context, 'finalize', adoptionId)); + const refs = context.service.adoption.refAdapter.readRefs([ + 'refs/heads/main', finalized.candidateRef, finalized.archiveRef, + ]); + assert.deepEqual(refs, { + 'refs/heads/main': context.base, + [finalized.candidateRef]: context.candidate, + [finalized.archiveRef]: context.base, + }); + assert.throws(() => context.service.adoption.refAdapter.compareAndSwapRefs({ + expected: [{ ref: finalized.candidateRef, sha: context.base }], + writes: [{ ref: finalized.candidateRef, sha: null }], + }), (error) => error.code === 'ADOPTION_REF_CAS_FAILED'); + assert.equal(context.service.adoption.getPrepared(adoptionId).state, 'finalized'); + context.control.close(); +}); + +test('SCN-integration-adoption-journal-and-binding: a duplicate preparation cannot overwrite the journal and lifecycle input cannot alter its immutable candidate binding', () => { + const context = fixture('torch-adoption-journal'); + const adoptionId = 'fixture-adoption'; + const prepared = prepare(context, adoptionId); + assert.throws(() => context.service.prepareAdoption(input(context, 'prepare', adoptionId)), + (error) => error.code === 'ADOPTION_RECORD_REVISION_STALE'); + assert.throws(() => context.service.finalizeAdoption(lifecycleInput(context, 'finalize', adoptionId, { candidateSha: context.base })), + (error) => error.code === 'ADOPTION_IMMUTABLE_BINDING_STALE'); + assert.throws(() => context.service.finalizeAdoption(lifecycleInput(context, 'finalize', adoptionId, { + expectedRecordRevision: prepared.revision + 1, + })), (error) => error.code === 'ADOPTION_RECORD_REVISION_STALE'); + const refs = context.service.adoption.refAdapter.readRefs([ + prepared.expectedOldRef, prepared.candidateRef, prepared.archiveRef, + ]); + assert.deepEqual(refs, { + [prepared.expectedOldRef]: context.base, + [prepared.candidateRef]: null, + [prepared.archiveRef]: null, + }); + const { mutationPerformed, ...durablePrepared } = prepared; + assert.equal(mutationPerformed, true); + assert.deepEqual(context.service.adoption.getPrepared(adoptionId), durablePrepared); + context.control.close(); +}); + +test('SCN-integration-adoption-lifecycle-revision-cas: stale or omitted revisions for every lifecycle action refuse before refs, journal, queue, or audit effects', () => { + const cases = [ + { + action: 'finalize', + setup: (context, adoptionId) => prepare(context, adoptionId), + invoke: (context, input) => context.service.finalizeAdoption(input), + }, + { + action: 'reconcile', + setup: (context, adoptionId) => prepare(context, adoptionId), + invoke: (context, input) => context.service.reconcileAdoption(input), + }, + { + action: 'rollback', + setup: (context, adoptionId) => { + prepare(context, adoptionId); + return context.service.finalizeAdoption(lifecycleInput(context, 'finalize', adoptionId)); + }, + invoke: (context, input) => context.service.rollbackAdoption(input), + }, + { + action: 'request', + setup: (context, adoptionId) => { + prepare(context, adoptionId); + return context.service.finalizeAdoption(lifecycleInput(context, 'finalize', adoptionId)); + }, + invoke: (context, input) => context.service.requestAdoptedCandidate(input), + }, + ]; + + for (const { action, setup, invoke } of cases) { + const context = fixture(`torch-adoption-${action}-revision-boundary`); + const adoptionId = 'fixture-adoption'; + setup(context, adoptionId); + for (const variant of ['omitted', 'stale']) { + const current = lifecycleInput(context, action, adoptionId); + const input = variant === 'omitted' + ? (({ expectedRecordRevision: _expectedRecordRevision, ...withoutRevision }) => withoutRevision)(current) + : { ...current, expectedRecordRevision: current.expectedRecordRevision + 1 }; + const before = lifecycleBoundary(context, adoptionId); + assert.throws(() => invoke(context, input), (error) => error.code === 'ADOPTION_RECORD_REVISION_STALE', + `${action} must reject a ${variant} lifecycle revision`); + assertLifecycleBoundaryUnchanged(context, adoptionId, before); + } + context.control.close(); + } +}); + +test('SCN-integration-adoption-finalize-recovery: a crash after real ref CAS but before the journal transition reconciles once and never duplicates refs', () => { + const context = fixture('torch-adoption-finalize-recovery'); + const adoptionId = 'fixture-adoption'; + const prepared = prepare(context, adoptionId); + context.service.adoption.refAdapter.compareAndSwapRefs({ + expected: [ + { ref: prepared.expectedOldRef, sha: prepared.baseTargetSha }, + { ref: prepared.candidateRef, sha: null }, + { ref: prepared.archiveRef, sha: null }, + ], + writes: [ + { ref: prepared.archiveRef, sha: prepared.baseTargetSha }, + { ref: prepared.candidateRef, sha: prepared.candidateSha }, + ], + }); + const recovered = context.service.reconcileAdoption(lifecycleInput(context, 'reconcile', adoptionId)); + assert.equal(recovered.state, 'recovered-finalized'); + assert.equal(recovered.record.recovery, 'private-ref-cas-completed-before-journal'); + const replay = context.service.reconcileAdoption(lifecycleInput(context, 'reconcile', adoptionId)); + assert.equal(replay.state, 'consistent'); + assert.equal(replay.mutationPerformed, false); + context.control.close(); +}); + +test('SCN-integration-adoption-rollback-recovery: a journal-missing rollback completes without a second deletion, while divergent candidate bytes refuse', () => { + const context = fixture('torch-adoption-rollback-recovery'); + const adoptionId = 'fixture-adoption'; + prepare(context, adoptionId); + const finalized = context.service.finalizeAdoption(lifecycleInput(context, 'finalize', adoptionId)); + assert.equal(finalized.state, 'finalized'); + context.service.adoption.refAdapter.compareAndSwapRefs({ + expected: [{ ref: finalized.candidateRef, sha: finalized.candidateSha }], + writes: [{ ref: finalized.candidateRef, sha: null }], + }); + const recovered = context.service.rollbackAdoption(lifecycleInput(context, 'rollback', adoptionId)); + assert.equal(recovered.state, 'rolled-back'); + assert.equal(recovered.recovery, 'private-ref-rollback-completed-before-journal'); + + const divergent = fixture('torch-adoption-rollback-divergence'); + const divergentId = 'fixture-adoption'; + prepare(divergent, divergentId); + const divergentFinal = divergent.service.finalizeAdoption(lifecycleInput(divergent, 'finalize', divergentId)); + divergent.service.adoption.refAdapter.compareAndSwapRefs({ + expected: [{ ref: divergentFinal.candidateRef, sha: divergentFinal.candidateSha }], + writes: [{ ref: divergentFinal.candidateRef, sha: divergent.base }], + }); + assert.throws(() => divergent.service.rollbackAdoption(lifecycleInput(divergent, 'rollback', divergentId)), + (error) => error.code === 'ADOPTION_ROLLBACK_REF_DIVERGED'); + context.control.close(); + divergent.control.close(); +}); + +test('SCN-integration-adoption-private-ref-intake: finalized private refs enter the ordinary native integration queue, not a specialist branch shortcut', () => { + const unfinalized = fixture('torch-adoption-unfinalized-intake'); + const unfinalizedId = 'fixture-adoption'; + const unfinalizedRef = `refs/torch/integration-candidates/${unfinalizedId}`; + unfinalized.service.adoption.refAdapter.compareAndSwapRefs({ + expected: [{ ref: unfinalizedRef, sha: null }], + writes: [{ ref: unfinalizedRef, sha: unfinalized.candidate }], + }); + assert.throws(() => unfinalized.service.requestCandidate({ + areaId: unfinalized.contributor, commit: unfinalized.candidate, candidateRef: unfinalizedRef, + }), (error) => error.code === 'INTEGRATION_ADOPTED_INTAKE_INTERNAL_ONLY'); + assert.deepEqual(unfinalized.service.list(), []); + unfinalized.control.close(); + + const context = fixture('torch-adoption-native-intake'); + const adoptionId = 'fixture-adoption'; + prepare(context, adoptionId); + const finalized = context.service.finalizeAdoption(lifecycleInput(context, 'finalize', adoptionId)); + assert.throws(() => context.service.requestAdoptedCandidate(lifecycleInput(context, 'request', adoptionId, { + expectedRecordRevision: finalized.revision + 1, + })), (error) => error.code === 'ADOPTION_RECORD_REVISION_STALE'); + assert.deepEqual(context.service.list(), []); + const requested = context.service.requestAdoptedCandidate(lifecycleInput(context, 'request', adoptionId)); + assert.equal(requested.state, 'requested'); + assert.equal(requested.integrationRequest.sourceBranch, finalized.candidateRef); + assert.equal(requested.integrationRequest.sourceCommit, context.candidate); + assert.equal(requested.integrationRequest.state, 'testing'); + assert.equal(requested.integrationRequest.targetBranch, 'main'); + context.control.close(); +}); + +test('SCN-integration-adoption-approval-boundaries: unrelated or source-only approval records cannot authorize an owner adoption, and actor, action, revision, and provenance mismatches each refuse', () => { + const unrelated = fixture('torch-adoption-unrelated-approval'); + const unrelatedId = 'fixture-adoption'; + const unrelatedApproval = unrelated.control.requestApproval({ + requester: unrelated.contributor, approver: 'owner', task: 'TASK-unrelated', + title: 'Ordinary source branch review', summary: 'Not an integration-adoption authority.', + }); + const unrelatedApproved = unrelated.control.decideApproval({ + approvalId: unrelatedApproval.id, decidedBy: 'owner', decision: 'approved', expectedRevision: unrelatedApproval.revision, + note: JSON.stringify({ schema: 'torch.dev/ordinary-source-review/v1alpha1', sourceArea: unrelated.contributor }), + }); + const unrelatedInput = { + actorId: 'owner', adoptionId: unrelatedId, candidateSha: unrelated.candidate, baseTargetSha: unrelated.base, + contributorArea: unrelated.contributor, destinationArea: unrelated.destination, targetBranch: 'main', + candidateRef: `refs/torch/integration-candidates/${unrelatedId}`, expectedOldRef: 'refs/heads/main', + approvalId: unrelatedApproved.id, approvalRevision: unrelatedApproved.revision, + provenance: ['fixture:owner-approved', `candidate:${unrelated.candidate}`], + provenanceManifestDigest: digest(['fixture:owner-approved', `candidate:${unrelated.candidate}`]), + adapter: { name: unrelated.service.adoption.refAdapter.name, id: unrelated.service.adoption.refAdapter.id }, + }; + assert.throws(() => unrelated.service.prepareAdoption(unrelatedInput), (error) => blockedCodes(error).includes('ADOPTION_APPROVAL_BINDING_INVALID')); + assert.deepEqual(unrelated.service.list(), []); + unrelated.control.close(); + + const wrongActor = fixture('torch-adoption-wrong-actor'); + assert.throws(() => wrongActor.service.prepareAdoption({ + ...input(wrongActor, 'prepare', 'fixture-adoption'), actorId: wrongActor.contributor, + }), (error) => blockedCodes(error).includes('OWNER_ACTOR_REQUIRED')); + wrongActor.control.close(); + + const wrongAction = fixture('torch-adoption-wrong-action'); + prepare(wrongAction, 'fixture-adoption'); + assert.throws(() => wrongAction.service.finalizeAdoption(lifecycleInput(wrongAction, 'rollback', 'fixture-adoption')), + (error) => blockedCodes(error).includes('ADOPTION_APPROVAL_BINDING_INVALID')); + wrongAction.control.close(); + + const staleRevision = fixture('torch-adoption-stale-revision'); + prepare(staleRevision, 'fixture-adoption'); + const staleInput = lifecycleInput(staleRevision, 'finalize', 'fixture-adoption'); + assert.throws(() => staleRevision.service.finalizeAdoption({ ...staleInput, approvalRevision: staleInput.approvalRevision - 1 }), + (error) => blockedCodes(error).includes('ADOPTION_APPROVAL_REVISION_STALE')); + staleRevision.control.close(); + + const wrongProvenance = fixture('torch-adoption-wrong-provenance'); + assert.throws(() => wrongProvenance.service.prepareAdoption({ + ...input(wrongProvenance, 'prepare', 'fixture-adoption'), provenance: ['fixture:owner-approved', 'unapproved:alternate-provenance'], + }), (error) => blockedCodes(error).includes('ADOPTION_PROVENANCE_DIGEST_MISMATCH')); + wrongProvenance.control.close(); +}); + +test('SCN-integration-adoption-request-replay: concurrent authenticated API callers serialize on the adoption journal and create exactly one private-ref queue request', async () => { + const context = fixture('torch-adoption-request-replay'); + const adoptionId = 'fixture-adoption'; + prepare(context, adoptionId); + context.service.finalizeAdoption(lifecycleInput(context, 'finalize', adoptionId)); + const requestInput = lifecycleInput(context, 'request', adoptionId); + const outcomes = await Promise.all([ + concurrentAdoptedRequest(context, requestInput), + concurrentAdoptedRequest(context, requestInput), + ]); + assert.deepEqual(outcomes.map((entry) => entry.outcome).sort(), ['refused', 'requested']); + assert.equal(outcomes.find((entry) => entry.outcome === 'refused').code, 'ADOPTION_RECORD_REVISION_STALE'); + const requests = context.service.list(); + assert.equal(requests.length, 1); + assert.equal(context.service.getPreparedAdoption(adoptionId).state, 'requested'); + context.control.close(); +}); + +test('SCN-integration-adoption-legacy-retries: historical ordinary-request retries remain readable when integration service starts', () => { + const context = fixture('torch-adoption-legacy-retries'); + const insert = context.control.database.prepare(`INSERT INTO integration_requests ( + id, project_id, source_area, source_branch, source_commit, target_branch, + base_target_commit, state, reason, required_checks, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); + for (const [id, state] of [['historical-failed', 'blocked'], ['historical-retry', 'superseded']]) { + insert.run(id, context.control.projectId, context.contributor, 'refs/heads/candidate', context.candidate, + 'main', context.base, state, 'historical-retry', '[]', '2026-10-03T00:00:00.000Z', '2026-10-03T00:00:00.000Z'); + } + assert.doesNotThrow(() => new IntegrationService({ + repositoryRoot: context.root, controlPlane: context.control, checkService: { exactPasses: () => false }, + })); + assert.equal(context.service.list().length, 2); + context.control.close(); +});