From c5577e1716fdd1420db02986c3299ebb2de3d0cb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 16:26:32 +0000 Subject: [PATCH] guard test: a stale Moment doesn't guard a write guard.ts says a Moment guard guards nothing, but test/integration/ guard.test.ts had only shown it for a query. The new live test covers a write, on its own throwaway Story: - An update filtered on a stale Moment (as a literal) previews as one command, and when sent it applies, overwriting the change made in between. - For contrast, the same update filtered on the stale ChangeDate literal, which is what guard.ts sends, previews as nothing and applies nothing. This pins the behaviour so the guard isn't switched to Moment later. guard.ts's comment now cites the write case. Found while checking the Agility MCP write-safety proposal; results are on #51. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016NSWj8mSZ8LrMj16R2cozL --- src/services/guard.ts | 5 ++++- test/integration/guard.test.ts | 26 +++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/services/guard.ts b/src/services/guard.ts index 28db6dc..61dc68e 100644 --- a/src/services/guard.ts +++ b/src/services/guard.ts @@ -21,7 +21,10 @@ * pattern first, so it cannot carry grammar. The ID stays a variable. * - Moment is NOT a stamp: `Moment=` * selects the asset as it was then (as-of), and an update scoped by it - * lands on the current asset. A Moment guard guards nothing. + * lands on the current asset. A Moment guard guards nothing. Proven for + * writes on 2026-11-11: an update filtered on a stale Moment previews as + * one command and applies, overwriting the change made in between + * ("why not Moment, on a write"; versionone/VersionOne.SDK.JavaScript#51). * Every type has ChangeDate except the Ideas product's (Idea*), which keeps * no history and cannot be guarded. * diff --git a/test/integration/guard.test.ts b/test/integration/guard.test.ts index 148923b..1a39e55 100644 --- a/test/integration/guard.test.ts +++ b/test/integration/guard.test.ts @@ -8,7 +8,8 @@ import { describeInstance, fixtureName, instance } from "./instance"; * afterwards by exact OID): one changed by "someone else" between the * preview and the send, one not. What only the server can prove: that the * projection's add/remove form for a scalar applies in a real update, that - * a ChangeDate literal is a true compare-and-set (and a Moment is not), and + * a ChangeDate literal is a true compare-and-set (and a Moment is not, for a + * query or for a write), and * that a stale guard writes nothing. */ describeInstance("guard (live)", () => { @@ -59,4 +60,27 @@ describeInstance("guard (live)", () => { expect(asOf).toHaveLength(1); // stale, yet it matches — expect(asOf[0]!.Estimate).toBe("8"); // — as the asset was at that moment }); + + it("why not Moment, on a write: an update filtered on a stale Moment previews as one command and applies, while a stale ChangeDate literal applies nothing", async () => { + // Its own Story, so the result doesn't depend on what the tests above did to a and b. + const res = await assetApi.send(t, [{ AssetType: "Story", Name: fixtureName("guard moment write"), Scope: { _oid: "Scope:0" }, Estimate: 1 }], { throwOnFailure: true }); + const s = assetApi.momentless(res.assetsCreated.oidTokens[0]!); + created.push(s); + const [before] = await assetApi.query<{ _oid: string; Moment: string; ChangeDate: string }>(t, { from: s, select: ["Moment", "ChangeDate"], page: { size: 1, start: 0 } }); + await assetApi.send(t, [{ from: s, update: { Estimate: 2 } }], { throwOnFailure: true }); // "someone else" changes it + expect(await estimate(s)).toBe("2"); + + // Stale Moment, as a literal: the projection still has the asset, and the write lands on the current one. + const staleMoment = [{ from: "Story", filter: ["ID=$oid", `Moment='${before!.Moment}'`], with: { $oid: s }, update: { Estimate: 7 } }]; + expect(await assetApi.preview(t, staleMoment)).toHaveLength(1); + const sent = await assetApi.send(t, staleMoment); + expect(sent.assetsModified.oidTokens.map(assetApi.momentless)).toEqual([s]); + expect(await estimate(s)).toBe("7"); // the other writer's 2 is overwritten: Moment guards nothing + + // Stale ChangeDate, as a literal (what guard.ts sends): matches nothing, so nothing is applied. + const staleStamp = [{ from: "Story", filter: ["ID=$oid", `ChangeDate='${before!.ChangeDate}'`], with: { $oid: s }, update: { Estimate: 9 } }]; + expect(await assetApi.preview(t, staleStamp)).toEqual([]); + expect((await assetApi.send(t, staleStamp)).assetsModified.count).toBe(0); + expect(await estimate(s)).toBe("7"); + }); });