From 44c279b9cd42e042cb20216029bfbc8092acdb29 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 00:03:04 -0700 Subject: [PATCH] fix: classify checked ref conflicts from posture --- CHANGELOG.md | 5 + .../deterministic-ref-conflict-posture.md | 340 ++++++++++++++++++ docs/design/README.md | 1 + src/infrastructure/adapters/GitRefAdapter.js | 94 +++-- src/ports/GitRefPort.js | 6 + test/integration/cache-set.test.js | 42 ++- .../adapters/GitRefAdapter.test.js | 219 ++++++++++- 7 files changed, 655 insertions(+), 52 deletions(-) create mode 100644 docs/design/0057-deterministic-ref-conflict-posture/deterministic-ref-conflict-posture.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 26b6e9f..56fdc98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Deterministic checked-ref conflicts** - failed checked updates, atomic + anchors, and checked deletes now classify concurrency from structured + post-failure ref posture instead of Git's human-readable diagnostics. + Operational failures remain unchanged when the observed ref still satisfies + the attempted precondition. - **Shared progress cursor ownership** - store and restore progress now use Bijou's reference-counted cursor guard and render the initial zero state without counting it as a processed chunk. diff --git a/docs/design/0057-deterministic-ref-conflict-posture/deterministic-ref-conflict-posture.md b/docs/design/0057-deterministic-ref-conflict-posture/deterministic-ref-conflict-posture.md new file mode 100644 index 0000000..12dbe4b --- /dev/null +++ b/docs/design/0057-deterministic-ref-conflict-posture/deterministic-ref-conflict-posture.md @@ -0,0 +1,340 @@ +--- +title: "TRUST-0057 - Deterministic Ref Conflict Posture" +cycle: "0057" +task_id: "deterministic-ref-conflict-posture" +legend: "TRUST" +release_home: "v6.5.6" +issue: "https://github.com/git-stunts/git-cas/issues/111" +goalpost_issue: "https://github.com/git-stunts/git-cas/issues/105" +tracker_source: "github" +status: "active" +base_commit: "e802269ab6035eae75c2d61a8e8a898800cffbb8" +owners: + - "@git-stunts" +sponsors: + human: "James" + agent: "Codex" +blocking_issues: [] +supersedes: [] +superseded_by: null +created: "2026-07-29" +updated: "2026-07-29" +--- + +# TRUST-0057 - Deterministic Ref Conflict Posture + +## Linked Issue + +- https://github.com/git-stunts/git-cas/issues/111 + +## Linked Tracker + +- Milestone: `v6.5.6` +- Goalpost issue: https://github.com/git-stunts/git-cas/issues/105 +- Slice issue: https://github.com/git-stunts/git-cas/issues/111 + +## Design Type + +- [x] Runtime/API +- [x] Storage/substrate +- [x] Migration/release +- [ ] CLI/operator +- [ ] Docs/public guidance +- [ ] TUI/visual surface +- [x] Test/tooling + +## Decision Summary + +Failed checked ref mutations are classified from structured post-failure ref +posture instead of human-readable Git diagnostics. The Git adapter observes +whether each relevant ref is symbolic, direct at a specific OID, or absent; +only posture that disproves the attempted precondition becomes the existing +conflict result or `GIT_REF_CONFLICT`. + +## Sponsored Human + +An operator wants acquisition and workspace release races to fail through the +documented conflict contract on every supported Git runtime, without depending +on a particular Git diagnostic string or locale. + +## Sponsored Agent + +An agent needs checked ref failures to carry a stable error code and structured +actual posture so it can distinguish concurrency from repository or process +failures without scraping stderr. + +## Hill + +By the end of this cycle, checked update, atomic anchor, and checked delete +failures are classified from ref state, and unit plus real-Git race tests prove +that diagnostic-free conflicts normalize while unrelated failures remain +unchanged. + +## Current Truth + +`GitRefAdapter.anchorRef()` and `deleteRef()` call +`isUpdateRefConflict()`, which searches lowercased error text for a ref name and +six English fragments. `updateRef()` has no post-failure normalization. A +checked-delete race can therefore escape as the plumbing error when Git reports +the mismatch without one of those fragments. Existing tests prove several +symbolic-ref races, but the unit conflict tests supply matching English stderr. + +## Problem + +Git diagnostics are explanatory prose, not a machine contract. Exit status +alone is also insufficient because compare-and-swap mismatches, lock failures, +permissions, missing repositories, and invalid objects may share an exit code. +The stable semantic evidence is the ref posture observed after the failed +checked operation. + +## Scope + +This cycle includes: + +- one structured post-failure inspection path for symbolic, direct, and absent + refs; +- posture-based normalization for `updateRef()`, `anchorRef()`, and + `deleteRef()`; +- removal of `isUpdateRefConflict()` and its diagnostic-text dependency; +- unit tests for diagnostic-free conflicts and preserved unrelated errors; +- real-Git checked-delete race coverage; +- port documentation and `v6.5.6` release notes. + +## Non-Goals + +This cycle does not include: + +- parsing or standardizing Git's stderr; +- treating every non-zero `update-ref` exit as a conflict; +- changing ref names, commit contents, object formats, or public method + signatures; +- adding retries or weakening fail-closed ownership behavior; +- changing the idempotency policy of higher-level release operations. + +## Runtime / API Contract + +The existing API remains: + +- `updateRef()` resolves on success and rejects with `GIT_REF_CONFLICT` when + observed posture disproves its compare-and-swap precondition; +- `anchorRef()` returns `false` when the source no longer names the expected + direct OID or the target is no longer absent; +- `deleteRef()` returns `true` after deletion and rejects with + `GIT_REF_CONFLICT` when the expected direct OID is absent, changed, or + replaced by a symbolic ref; +- operational failures whose post-failure posture still satisfies the attempted + precondition preserve the original error object. + +## User Experience / Product Shape + +There is no rendered surface. The operator- and agent-visible contract is the +stable error code and structured conflict metadata returned by existing APIs. + +## Data / State Model + +| State | Source of truth | Derived state | Invalid states | Reset behavior | Serialization | Determinism assumptions | +| --- | --- | --- | --- | --- | --- | --- | +| Ref posture | Git ref database after a failed mutation | `direct`, `symbolic`, or `absent` observation | Malformed structured ref inventory | Fail with the inventory error | Git `symbolic-ref` plus `for-each-ref` output | Classification uses values, not diagnostic prose | + +## Architecture / Anti-SLUDGE Posture + +| Concern | Decision | +| --- | --- | +| Domain changes | None | +| Port changes | Clarify the existing checked-mutation error contract | +| Adapter changes | Centralize structured post-failure posture inspection | +| Boundary validation | Ref names and OIDs retain existing validation | +| Runtime-backed nouns introduced | None | +| Expected failure representation | Existing boolean conflict result or `GIT_REF_CONFLICT` | +| Banned shortcuts avoided | No stderr matching, exit-code-only classification, or blanket conversion | + +## Cost / Residency Posture + +| Surface | Current cost | Target cost | Limit/budget | Failure mode | +| --- | --- | --- | --- | --- | +| Successful ref mutation | Existing preflight plus one mutation | Unchanged | No added reads | Existing operational error | +| Failed checked mutation | Diagnostic parsing, then up to two ref reads | Up to two structured ref reads | Relevant refs only | Original error if posture does not prove conflict | + +## Determinism / Replay / Causality + +Classification is deterministic for the observed post-failure posture. The +design does not claim a global atomic snapshot after Git rejects the mutation; +it claims that no English diagnostic is needed and that every normalized +conflict carries the state actually observed by the adapter. + +## Git Substrate Impact + +| Substrate area | Impact | +| --- | --- | +| refs | Failed checked mutations gain structured post-failure inspection | +| commits | None | +| trees/blobs | None | +| object ids | Compared canonically through `Oid` | +| tag/release behavior | Patch release `v6.5.6` | +| migration compatibility | No data migration | + +## Compatibility / Migration Posture + +| Concern | Decision | +| --- | --- | +| Public API compatibility | Signatures unchanged; documented conflict behavior becomes reliable | +| Package export changes | None | +| Storage/read compatibility | Unchanged | +| Legacy behavior retained | Non-conflict operational errors remain original | +| Deprecation behavior | None | +| Migration path | Upgrade package only | +| Release note impact | Fixed in `v6.5.6` | + +## Error Contract + +| Failure | Error/result | Caller recovery | Test | +| --- | --- | --- | --- | +| Update expected old OID changed, disappeared, or became symbolic | `GIT_REF_CONFLICT` with observed posture | Retry from fresh state | Unit | +| Anchor source changed or target appeared | `false` | Reacquire from fresh source | Unit and integration | +| Checked delete target changed, disappeared, or became symbolic | `GIT_REF_CONFLICT` with observed posture | Treat release as conflicted and inspect | Unit and integration | +| Mutation fails while checked posture still holds | Original error object | Address repository/process failure | Unit | +| Structured inspection is malformed or fails | Inspection error | Address repository/process failure | Existing inventory validation | + +## Security / Trust / Redaction Posture + +- trust boundary: the Git adapter owns ref mutation authority; +- authority or capability checked: exact ref name, expected direct OID, and + no-dereference behavior; +- secret-bearing values: none; +- redaction behavior: unchanged; +- abuse or replay concern: a concurrent actor cannot turn a managed ref into a + symbolic authority path without conflict detection. + +## Lower Modes + +The lower mode is the existing machine-readable `CasError` with +`GIT_REF_CONFLICT` and `ref`, `expectedOldOid`, `actualOldOid`, and +`actualSymref` metadata. + +## Accessibility Posture + +No visual interaction changes. Design, errors, tests, and witness output use a +linear text order and do not rely on color or terminal layout. + +## User-Facing Text / Directionality + +No new interactive text is introduced. Existing conflict messages retain their +wording; machine behavior depends on error codes and metadata. + +## Agent Inspectability / Explainability Posture + +An agent can inspect the exact expected and actual ref posture through error +metadata. It does not need locale-specific Git text or private adapter state. + +## Linked Invariants + +- [I-001 - Determinism, Trust, And Explicit Surfaces](../../invariants/I-001-determinism-trust-and-explicit-surfaces.md) +- Managed ref mutation never follows a symbolic ref. +- Compare-and-swap mismatches fail through explicit conflict contracts. +- Unrelated operational failures are not mislabeled as concurrency. + +## Design Alternatives Considered + +### Option A: Match more diagnostic strings + +Rejected because the classification remains version-, locale-, and +wording-dependent. + +### Option B: Use only the Git exit code + +Rejected because Git does not assign a unique exit code to compare-and-swap +conflicts. + +### Option C: Re-read structured ref posture + +Selected because it tests the semantic precondition directly while preserving +unexplained failures. + +## Decision + +Use option C. Catch failures only around known ref mutations, inspect the +relevant refs, normalize only when the observed posture disproves the +operation's precondition, and otherwise rethrow the original error. + +## Proof Surface + +- actual surface under test: `GitRefAdapter` checked ref operations; +- first RED tests: diagnostic-free update, anchor, and delete conflicts; +- required runtime witness: real-Git checked-delete race with a diagnostic-free + wrapper error; +- non-acceptable proof: asserting on Git stderr text. + +## Implementation Slices + +- Add posture-based RED unit tests. +- Add structured post-failure inspection and remove diagnostic parsing. +- Add or revise the real-Git race witness. +- Update the port contract, changelog, and design lifecycle. +- Validate, merge, and publish `v6.5.6`. + +## Tests To Write First + +- [x] Diagnostic-free failed update becomes `GIT_REF_CONFLICT` when the OID + changed. +- [x] Diagnostic-free failed anchor returns `false` when source or target + posture changed. +- [x] Diagnostic-free failed delete becomes `GIT_REF_CONFLICT` when the ref + disappeared, changed, or became symbolic. +- [x] Each operation preserves its original error when posture still satisfies + the attempted precondition. +- [x] Real Git proves the checked-delete race without diagnostic matching. + +## Acceptance Criteria + +- [x] No checked ref classification depends on diagnostic text. +- [x] The three mutation methods implement the documented posture decision. +- [x] Focused unit and real-Git integration tests pass. +- [ ] Full lint, unit, integration, and release verification pass. +- [ ] PR review and CI are green. +- [ ] `v6.5.6` is tagged, published to npm, and represented by a GitHub Release. + +## Validation Plan + +```bash +pnpm vitest run test/unit/infrastructure/adapters/GitRefAdapter.test.js +pnpm vitest run test/integration/cache-set.test.js --no-file-parallelism +pnpm run lint +pnpm test +pnpm run release:verify +``` + +The final release workflow repeats lint, unit, and Node/Bun/Deno integration +validation from the reviewed tag. + +## Playback / Witness + +The witness is the linked pull request plus CI and release workflow. It records +the RED diagnostic-free tests, GREEN unit and real-Git results, full verifier +counts, merged SHA, signed tag, npm version, and GitHub Release URL. + +## Risks + +The ref can change again after post-failure inspection. The design therefore +reports observed posture and does not claim a durable lock. False conflict +classification is bounded by requiring the observed posture to contradict the +attempted precondition. + +## Follow-On Debt + +None currently. New debt must be filed as a GitHub issue. + +## Tracker Disposition + +| Issue | Role | Expected disposition | +| --- | --- | --- | +| https://github.com/git-stunts/git-cas/issues/111 | primary | close when `v6.5.6` publication is verified | + +## Done Does Not Mean + +This cycle does not make Git ref inspection and mutation one atomic operation, +introduce distributed locking, or promise that all Git failures have dedicated +Git exit codes. + +## Retrospective + +Pending implementation and release. diff --git a/docs/design/README.md b/docs/design/README.md index 2a27cfe..4469c9e 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -11,6 +11,7 @@ process in [docs/method/process.md](../method/process.md). ## Active METHOD Cycles +- [0057-deterministic-ref-conflict-posture - deterministic-ref-conflict-posture](./0057-deterministic-ref-conflict-posture/deterministic-ref-conflict-posture.md) - [0056-bijou-7-framed-cockpit - bijou-7-framed-cockpit](./0056-bijou-7-framed-cockpit/bijou-7-framed-cockpit.md) - [0054-batched-page-retention - batched-page-retention](./0054-batched-page-retention/batched-page-retention.md) - [0050-lazy-bundle-reference-reads - lazy-bundle-reference-reads](./0050-lazy-bundle-reference-reads/lazy-bundle-reference-reads.md) diff --git a/src/infrastructure/adapters/GitRefAdapter.js b/src/infrastructure/adapters/GitRefAdapter.js index 86528c8..27d75c2 100644 --- a/src/infrastructure/adapters/GitRefAdapter.js +++ b/src/infrastructure/adapters/GitRefAdapter.js @@ -1,7 +1,7 @@ import { Policy } from '@git-stunts/alfred'; import GitRefPort from '../../ports/GitRefPort.js'; import { CasError, ErrorCodes } from '../../domain/errors/index.js'; -import { errorDetailsText, isGitMissingRefError } from '../../domain/helpers/gitRefErrors.js'; +import { isGitMissingRefError } from '../../domain/helpers/gitRefErrors.js'; import Oid from '../../domain/value-objects/Oid.js'; /** @@ -136,9 +136,22 @@ export default class GitRefAdapter extends GitRefPort { if (expectedOldOid !== undefined) { args.push(expectedOldOid ?? '0'.repeat(newOid.length)); } - await this.policy.execute(() => - this.plumbing.execute({ args }), - ); + try { + await this.policy.execute(() => + this.plumbing.execute({ args }), + ); + } catch (error) { + const actual = await this.#inspectRefPosture(ref); + if (postureDisprovesExpectedOid(actual, expectedOldOid)) { + throw refConflictFromPosture({ + ref, + expectedOldOid: expectedOldOid ?? null, + actual, + originalError: error, + }); + } + throw error; + } } /** @override */ @@ -168,7 +181,12 @@ export default class GitRefAdapter extends GitRefPort { ); return true; } catch (error) { - if (isUpdateRefConflict(error, [sourceRef, targetRef])) { + const sourceActual = await this.#inspectRefPosture(sourceRef); + if (postureDisprovesExpectedOid(sourceActual, generation)) { + return false; + } + const targetActual = await this.#inspectRefPosture(targetRef); + if (postureDisprovesExpectedOid(targetActual, null)) { return false; } throw error; @@ -197,27 +215,16 @@ export default class GitRefAdapter extends GitRefPort { }), ); } catch (error) { - if (!isUpdateRefConflict(error, [ref])) { - throw error; - } - const racedSymbolicTarget = await this.#resolveSymbolicRef(ref); - if (racedSymbolicTarget !== null) { - throw refConflict({ + const actual = await this.#inspectRefPosture(ref); + if (postureDisprovesExpectedOid(actual, expectedGeneration)) { + throw refConflictFromPosture({ ref, expectedOldOid: expectedGeneration, - actualOldOid: null, - actualSymref: racedSymbolicTarget, + actual, originalError: error, }); } - const actual = await this.#inspectDirectRef(ref); - throw refConflict({ - ref, - expectedOldOid: expectedGeneration, - actualOldOid: actual?.oid ?? null, - actualSymref: actual?.symref ?? null, - originalError: error, - }); + throw error; } return true; } @@ -270,6 +277,18 @@ export default class GitRefAdapter extends GitRefPort { }); } + async #inspectRefPosture(ref) { + const symbolicTarget = await this.#resolveSymbolicRef(ref); + if (symbolicTarget !== null) { + return Object.freeze({ oid: null, symref: symbolicTarget }); + } + const direct = await this.#inspectDirectRef(ref); + if (direct?.symref) { + return Object.freeze({ oid: null, symref: direct.symref }); + } + return Object.freeze({ oid: direct?.oid ?? null, symref: null }); + } + async #resolveSymbolicRef(ref) { try { const output = await this.policy.execute(() => @@ -443,17 +462,18 @@ function invalidRefName(ref) { return new CasError('Git ref name or prefix is invalid', ErrorCodes.GIT_ERROR, { ref }); } -function isUpdateRefConflict(error, refs) { - const text = errorDetailsText(error).toLowerCase(); - return refs.some((ref) => text.includes(ref.toLowerCase())) - && ( - text.includes('cannot lock ref') || - text.includes('but expected') || - text.includes('reference already exists') || - text.includes('unable to resolve reference') || - text.includes('zero ') || - text.includes('is a symref') - ); +function postureDisprovesExpectedOid(actual, expectedOldOid) { + if (actual.symref !== null) { + return true; + } + if (expectedOldOid === undefined) { + return false; + } + if (expectedOldOid === null) { + return actual.oid !== null; + } + return actual.oid === null + || Oid.from(actual.oid).toString() !== Oid.from(expectedOldOid).toString(); } function isQuietSymbolicRefMiss(error, ref) { @@ -479,3 +499,13 @@ function refConflict({ { ref, expectedOldOid, actualOldOid, actualSymref, originalError }, ); } + +function refConflictFromPosture({ ref, expectedOldOid, actual, originalError }) { + return refConflict({ + ref, + expectedOldOid, + actualOldOid: actual.oid, + actualSymref: actual.symref, + originalError, + }); +} diff --git a/src/ports/GitRefPort.js b/src/ports/GitRefPort.js index 18bd165..6330c58 100644 --- a/src/ports/GitRefPort.js +++ b/src/ports/GitRefPort.js @@ -54,6 +54,8 @@ export default class GitRefPort { * @param {string} _options.newOid - New OID to set. * @param {string|null} [_options.expectedOldOid] - Expected current OID for CAS; `null` means the ref must not exist. * @returns {Promise} + * @throws A `GIT_REF_CONFLICT` error when observed ref posture disproves the + * checked mutation precondition. */ async updateRef(_options) { throw new Error('Not implemented'); @@ -66,6 +68,8 @@ export default class GitRefPort { * @param {string} _options.expectedSourceOid * @param {string} _options.targetRef * @returns {Promise} Whether the source generation was anchored. + * `false` means observed ref posture disproved the atomic verify/create + * preconditions. */ async anchorRef(_options) { throw unsupportedAcquisitionCapability('anchorRef'); @@ -77,6 +81,8 @@ export default class GitRefPort { * @param {string} _options.ref * @param {string} _options.expectedOldOid * @returns {Promise} Whether the ref existed and was deleted. + * @throws A `GIT_REF_CONFLICT` error when observed ref posture disproves the + * checked delete precondition. */ async deleteRef(_options) { throw unsupportedAcquisitionCapability('deleteRef'); diff --git a/test/integration/cache-set.test.js b/test/integration/cache-set.test.js index d39e127..65c1e13 100644 --- a/test/integration/cache-set.test.js +++ b/test/integration/cache-set.test.js @@ -94,6 +94,37 @@ function hookPlumbing(plumbing, hook) { }); } +function stripMutationDiagnostics(plumbing) { + return new Proxy(plumbing, { + get(target, property) { + const value = Reflect.get(target, property, target); + if (typeof value !== 'function') { + return value; + } + if (property === 'execute') { + return async (options) => { + try { + return await value.call(target, options); + } catch (error) { + if (options.args[0] !== 'update-ref' || !options.args.includes('-d')) { + throw error; + } + throw Object.assign(new Error('checked ref mutation failed'), { + details: { + args: options.args, + code: error?.details?.code ?? 128, + stderr: '', + stdout: '', + }, + }); + } + }; + } + return value.bind(target); + }, + }); +} + function tracedPlumbing(plumbing) { return new Proxy(plumbing, { get(target, property) { @@ -463,13 +494,13 @@ describe('CacheSet acquisition target ref authority', () => { const plumbing = await createGitPlumbing({ cwd: repoDir }); let raced = false; const racingRefs = new GitRefAdapter({ - plumbing: hookPlumbing(plumbing, ({ args }) => { + plumbing: stripMutationDiagnostics(hookPlumbing(plumbing, ({ args }) => { if (!raced && args[0] === 'update-ref' && args.includes('-d')) { raced = true; git(['update-ref', '-d', acquisitionRef]); git(['symbolic-ref', acquisitionRef, danglingTarget]); } - }), + })), }); await expect(racingRefs.deleteRef({ @@ -477,7 +508,12 @@ describe('CacheSet acquisition target ref authority', () => { expectedOldOid: stored.generation, })).rejects.toMatchObject({ code: 'GIT_REF_CONFLICT', - meta: { actualSymref: danglingTarget }, + meta: { + actualSymref: danglingTarget, + originalError: { + details: { stderr: '' }, + }, + }, }); expect(raced).toBe(true); diff --git a/test/unit/infrastructure/adapters/GitRefAdapter.test.js b/test/unit/infrastructure/adapters/GitRefAdapter.test.js index ef106ca..bb78028 100644 --- a/test/unit/infrastructure/adapters/GitRefAdapter.test.js +++ b/test/unit/infrastructure/adapters/GitRefAdapter.test.js @@ -15,6 +15,33 @@ function createAdapter() { }; } +function createFailedMutationAdapter({ mutationError, postures }) { + let mutationAttempted = false; + const plumbing = { + execute: vi.fn().mockImplementation(({ args }) => { + if (args[0] === 'update-ref') { + mutationAttempted = true; + return Promise.reject(mutationError); + } + const ref = args.at(-1); + const posture = mutationAttempted ? postures[ref] : null; + if (args[0] === 'symbolic-ref') { + return Promise.resolve(posture?.symref ?? ''); + } + if (args[0] === 'for-each-ref') { + return Promise.resolve(posture?.oid + ? `${ref}\t${posture.oid}\t${posture.symref ?? ''}` + : ''); + } + return Promise.resolve(''); + }), + }; + return { + adapter: new GitRefAdapter({ plumbing, policy: noPolicy }), + plumbing, + }; +} + describe('GitRefAdapter.resolveRef()', () => { it('normalizes Git missing-ref stderr into a structured ref-not-found error', async () => { const { adapter, plumbing } = createAdapter(); @@ -163,6 +190,73 @@ describe('GitRefAdapter.updateRef()', () => { }); expect(plumbing.execute).toHaveBeenCalledTimes(1); }); + + it('normalizes a diagnostic-free checked update from observed OID posture', async () => { + const ref = 'refs/cas/vault'; + const expectedOldOid = 'a'.repeat(40); + const actualOldOid = 'b'.repeat(40); + const rootCause = new Error('update failed'); + const { adapter } = createFailedMutationAdapter({ + mutationError: rootCause, + postures: { [ref]: { oid: actualOldOid, symref: null } }, + }); + + await expect(adapter.updateRef({ + ref, + newOid: 'c'.repeat(40), + expectedOldOid, + })).rejects.toMatchObject({ + code: ErrorCodes.GIT_REF_CONFLICT, + meta: { + ref, + expectedOldOid, + actualOldOid, + actualSymref: null, + originalError: rootCause, + }, + }); + }); + + it('normalizes a diagnostic-free create-only update when the target appeared', async () => { + const ref = 'refs/cas/vault'; + const actualOldOid = 'b'.repeat(40); + const rootCause = new Error('update failed'); + const { adapter } = createFailedMutationAdapter({ + mutationError: rootCause, + postures: { [ref]: { oid: actualOldOid, symref: null } }, + }); + + await expect(adapter.updateRef({ + ref, + newOid: 'c'.repeat(40), + expectedOldOid: null, + })).rejects.toMatchObject({ + code: ErrorCodes.GIT_REF_CONFLICT, + meta: { + ref, + expectedOldOid: null, + actualOldOid, + actualSymref: null, + originalError: rootCause, + }, + }); + }); + + it('preserves a failed checked update when observed posture still satisfies it', async () => { + const ref = 'refs/cas/vault'; + const expectedOldOid = 'a'.repeat(40); + const rootCause = new Error('permission denied'); + const { adapter } = createFailedMutationAdapter({ + mutationError: rootCause, + postures: { [ref]: { oid: expectedOldOid, symref: null } }, + }); + + await expect(adapter.updateRef({ + ref, + newOid: 'b'.repeat(40), + expectedOldOid, + })).rejects.toBe(rootCause); + }); }); describe('GitRefAdapter scoped anchors', () => { @@ -243,33 +337,124 @@ describe('GitRefAdapter scoped-anchor conflicts', () => { expect(plumbing.execute).toHaveBeenCalledTimes(1); }); - it('reports a generation race without hiding unrelated Git failures', async () => { - const { adapter, plumbing } = createAdapter(); + it('normalizes a diagnostic-free anchor failure from observed source posture', async () => { const sourceRef = 'refs/cas/caches/git-warp/materializations'; const targetRef = 'refs/cas/cache-acquisitions/git-warp/materializations/acquisition'; const generation = 'a'.repeat(40); - plumbing.execute - .mockResolvedValueOnce('') - .mockResolvedValueOnce('') - .mockRejectedValueOnce(Object.assign(new Error('transaction failed'), { - details: { stderr: `fatal: prepare: cannot lock ref '${sourceRef}': is at b but expected a` }, - })); + const { adapter } = createFailedMutationAdapter({ + mutationError: new Error('transaction failed'), + postures: { + [sourceRef]: { oid: 'b'.repeat(40), symref: null }, + [targetRef]: null, + }, + }); - await expect(adapter.anchorRef({ sourceRef, expectedSourceOid: generation, targetRef })) - .resolves.toBe(false); + await expect(adapter.anchorRef({ + sourceRef, + expectedSourceOid: generation, + targetRef, + })).resolves.toBe(false); + }); +}); - const failure = new Error('permission denied'); - plumbing.execute - .mockResolvedValueOnce('') - .mockResolvedValueOnce('') - .mockRejectedValueOnce(failure); - await expect(adapter.anchorRef({ sourceRef, expectedSourceOid: generation, targetRef })) - .rejects.toBe(failure); +describe('GitRefAdapter scoped-anchor target and operational failures', () => { + it('normalizes a diagnostic-free anchor failure when the target appeared', async () => { + const sourceRef = 'refs/cas/caches/git-warp/materializations'; + const targetRef = 'refs/cas/cache-acquisitions/git-warp/materializations/acquisition'; + const generation = 'a'.repeat(40); + const { adapter } = createFailedMutationAdapter({ + mutationError: new Error('transaction failed'), + postures: { + [sourceRef]: { oid: generation, symref: null }, + [targetRef]: { oid: generation, symref: null }, + }, + }); + + await expect(adapter.anchorRef({ + sourceRef, + expectedSourceOid: generation, + targetRef, + })).resolves.toBe(false); + }); + + it('preserves a failed anchor when observed posture still satisfies it', async () => { + const sourceRef = 'refs/cas/caches/git-warp/materializations'; + const targetRef = 'refs/cas/cache-acquisitions/git-warp/materializations/acquisition'; + const generation = 'a'.repeat(40); + const rootCause = new Error('permission denied'); + const { adapter } = createFailedMutationAdapter({ + mutationError: rootCause, + postures: { + [sourceRef]: { oid: generation, symref: null }, + [targetRef]: null, + }, + }); + + await expect(adapter.anchorRef({ + sourceRef, + expectedSourceOid: generation, + targetRef, + })).rejects.toBe(rootCause); }); }); // eslint-disable-next-line max-lines-per-function describe('GitRefAdapter checked-delete conflicts', () => { + it('normalizes a diagnostic-free checked delete from observed absence', async () => { + const ref = 'refs/cas/cache-acquisitions/git-warp/materializations/acquisition'; + const generation = 'a'.repeat(40); + const rootCause = new Error('delete failed'); + const { adapter } = createFailedMutationAdapter({ + mutationError: rootCause, + postures: { [ref]: null }, + }); + + await expect(adapter.deleteRef({ ref, expectedOldOid: generation })).rejects.toMatchObject({ + code: ErrorCodes.GIT_REF_CONFLICT, + meta: { + ref, + expectedOldOid: generation, + actualOldOid: null, + actualSymref: null, + originalError: rootCause, + }, + }); + }); + + it('normalizes a diagnostic-free checked delete from an observed OID mismatch', async () => { + const ref = 'refs/cas/cache-acquisitions/git-warp/materializations/acquisition'; + const generation = 'a'.repeat(40); + const actualOldOid = 'b'.repeat(40); + const rootCause = new Error('delete failed'); + const { adapter } = createFailedMutationAdapter({ + mutationError: rootCause, + postures: { [ref]: { oid: actualOldOid, symref: null } }, + }); + + await expect(adapter.deleteRef({ ref, expectedOldOid: generation })).rejects.toMatchObject({ + code: ErrorCodes.GIT_REF_CONFLICT, + meta: { + ref, + expectedOldOid: generation, + actualOldOid, + actualSymref: null, + originalError: rootCause, + }, + }); + }); + + it('preserves a failed checked delete when observed posture still satisfies it', async () => { + const ref = 'refs/cas/cache-acquisitions/git-warp/materializations/acquisition'; + const generation = 'a'.repeat(40); + const rootCause = new Error('permission denied'); + const { adapter } = createFailedMutationAdapter({ + mutationError: rootCause, + postures: { [ref]: { oid: generation, symref: null } }, + }); + + await expect(adapter.deleteRef({ ref, expectedOldOid: generation })).rejects.toBe(rootCause); + }); + it('fails closed when a checked-delete conflict leaves no inspectable direct ref', async () => { const { adapter, plumbing } = createAdapter(); const ref = 'refs/cas/cache-acquisitions/git-warp/materializations/acquisition';