From c7a369f436ce85ab6569baab3cd68fa55645bdb7 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sun, 9 Aug 2026 18:45:56 +0200 Subject: [PATCH] fix: preserve evidence revision staleness semantics --- backend/src/workspaces/registry.ts | 6 +++++ backend/test/routes-workspaces.test.ts | 19 ++++++++++++++++ backend/test/workspace-registry.test.ts | 29 ++++++++++++++++++++----- 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/backend/src/workspaces/registry.ts b/backend/src/workspaces/registry.ts index a495b1d7..913785b7 100644 --- a/backend/src/workspaces/registry.ts +++ b/backend/src/workspaces/registry.ts @@ -383,6 +383,12 @@ export class WorkspaceRegistry { if (request.baseCommit !== status.head || ( request.action !== "create" && existing?.blob !== request.baseBlob )) { + const contentOnlyStale = request.action !== "create" + && request.baseCommit !== status.head + && existing?.blob === request.baseBlob; + if (contentOnlyStale) { + throw new WorkspaceRegistryError("workspace_stale", "Workspace revision is stale"); + } throw await this.conflictFor(request, status.head!, existing, local); } if (request.action === "create" && existing) throw await this.conflictFor(request, status.head!, existing, local); diff --git a/backend/test/routes-workspaces.test.ts b/backend/test/routes-workspaces.test.ts index 481a86fa..f9f9e931 100644 --- a/backend/test/routes-workspaces.test.ts +++ b/backend/test/routes-workspaces.test.ts @@ -321,6 +321,25 @@ test("returns a 409 field conflict instead of overwriting a changed workspace", }); }); +test("maps a stale registry commit to HTTP 409 without conflict payloads", async () => { + const registry = registryFake({ + publish: vi.fn(async () => { + throw new WorkspaceRegistryError("workspace_stale", "Workspace revision is stale"); + }), + }); + const app = appFor(registry); + + const res = await app.inject({ method: "POST", url: "/workspaces/publish", payload: { + action: "update", + workspace, + baseCommit: "c".repeat(40), + baseBlob: "d".repeat(40), + } }); + + expect(res.statusCode).toBe(409); + expect(res.json()).toEqual({ code: "workspace_stale", message: "Workspace revision is stale." }); +}); + test("exports generated public artifacts without secret values", async () => { const app = appFor(registryFake()); diff --git a/backend/test/workspace-registry.test.ts b/backend/test/workspace-registry.test.ts index 7be4cb24..95e7913c 100644 --- a/backend/test/workspace-registry.test.ts +++ b/backend/test/workspace-registry.test.ts @@ -408,6 +408,29 @@ test.each(["missing", "blob"])( }, ); +test("activation validates filesystem Evidence against its exact safeHead rather than checkout HEAD", async () => { + const remote = await fixture(withFilesystemEvidence(validYaml)); + const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); + await registry.bootstrap(); + rmSync(join(remote.source, "workspace-content", "psd-clinical", "evidence"), { + recursive: true, force: true, + }); + await git(remote.source, ["add", "-A", "workspace-content/psd-clinical/evidence"]); + await git(remote.source, ["commit", "-m", "Remove current Evidence root"]); + await git(remote.source, ["push", "origin", "main"]); + const invalidHead = await gitOutput(remote.source, ["rev-parse", "HEAD"]); + const internals = registry as unknown as { + repository: { pull(): Promise<{ head?: string }> }; + activate(commit: string): Promise; + }; + + expect((await internals.repository.pull()).head).toBe(invalidHead); + await expect(internals.activate(remote.initialCommit)).resolves.toBeUndefined(); + await expect(registry.read("psd-clinical")).resolves.toMatchObject({ + revision: { commit: remote.initialCommit }, + }); +}); + test("creates an immutable descriptor revision for a content-only Evidence commit", async () => { const remote = await fixture(withFilesystemEvidence(validYaml)); const root = join(remote.root, "registry"); @@ -454,11 +477,7 @@ test("rejects a stale API update after a content-only Evidence commit", async () workspace: filesystemWorkspace("psd-clinical"), baseCommit: initial.revision.commit, baseBlob: initial.revision.blob, - })).rejects.toMatchObject({ - code: "workspace_conflict", - expected: { commit: initial.revision.commit, blob: initial.revision.blob }, - actual: { commit: curatorCommit, blob: initial.revision.blob }, - }); + })).rejects.toMatchObject({ code: "workspace_stale" }); expect(await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"])).toBe(curatorCommit); });