diff --git a/docs/TEST_INTEGRITY.md b/docs/TEST_INTEGRITY.md index c17dc32..7f9c360 100644 --- a/docs/TEST_INTEGRITY.md +++ b/docs/TEST_INTEGRITY.md @@ -3199,3 +3199,47 @@ test_integrity_note: 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 expectedRecordRevision to finalize or request native qualification" + then: "The operation refuses before private-ref or queue effects; the original refs, journal state, and queue remain unchanged" + observable_outcomes: + - "ADOPTION_RECORD_REVISION_STALE is returned for stale lifecycle callers" + - "Private candidate/archive refs remain absent before stale finalize and no integration request is created by stale native request" + 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" + 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: "" +``` diff --git a/src/integration/adoption.mjs b/src/integration/adoption.mjs index 9d5eabd..4d2c7d5 100644 --- a/src/integration/adoption.mjs +++ b/src/integration/adoption.mjs @@ -88,6 +88,12 @@ function exactInput(record, input) { 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 }, + }); + } } /** @@ -334,6 +340,7 @@ export class AdoptionService { 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 }, diff --git a/test/fleet/integration-adoption.test.mjs b/test/fleet/integration-adoption.test.mjs index 741149e..666e6f6 100644 --- a/test/fleet/integration-adoption.test.mjs +++ b/test/fleet/integration-adoption.test.mjs @@ -130,7 +130,8 @@ function prepare(context, adoptionId = 'fixture-adoption') { function lifecycleInput(context, action, adoptionId, extra = {}) { const { approvalId, approvalRevision, adapter } = authority(context, { action, adoptionId }); - return { actorId: 'owner', adoptionId, approvalId, approvalRevision, adapter, ...extra }; + const record = context.service.getPreparedAdoption(adoptionId); + return { actorId: 'owner', adoptionId, expectedRecordRevision: record.revision, approvalId, approvalRevision, adapter, ...extra }; } function blockedCodes(error) { @@ -167,6 +168,17 @@ test('SCN-integration-adoption-journal-and-binding: a duplicate preparation cann (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); @@ -243,6 +255,10 @@ test('SCN-integration-adoption-private-ref-intake: finalized private refs enter 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); @@ -313,7 +329,7 @@ test('SCN-integration-adoption-request-replay: concurrent authenticated API call concurrentAdoptedRequest(context, requestInput), ]); assert.deepEqual(outcomes.map((entry) => entry.outcome).sort(), ['refused', 'requested']); - assert.equal(outcomes.find((entry) => entry.outcome === 'refused').code, 'ADOPTION_REQUEST_STATE_INVALID'); + 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');