VM-005 B1: write-side mission-status cache coherence (narrow C-vii exception)

POST /:missionId/complete writes the mission README status but left the
authored-status sidecar TTL-hot, so /api/slices served the pre-write word
for up to 60s while /api/missions/:id read the new word from disk — an
FR-1 coherence violation through the daemon's own API (guard final
product review). Per the arch ruling (b8d91aee…, drop-the-blob) and plan
v1.6.1 §J-1: one narrow SliceIndexer.invalidateMissionStatusCache()
nulling ONLY the missionStatusCache blob, exactly one additive
post-success call in the /complete route (writeMissionStatusComplete and
readMissionStatus stay byte-unchanged — the C-vii fence holds around the
one-line exception), and the §J-1d regression: hot-primed sidecar →
POST complete → immediate detail+slices coherence, idempotent re-POST,
and the negative that listing/detail caches survive. Out-of-band writes
remain the TTL regime by design. Zero UI edits.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
mvschwarzandClaude Fable 5 committed 2026-07-11 05:54:41 -07:00
1 parent c746883cba
commit 704ddb58d9
3 files changed
+88

No files matched your search

@@ -199,6 +199,20 @@ export class SliceIndexer {
this.missionStatusCache = null;
}
/** VM-005 B1 (the narrow C-vii exception; arch ruling b8d91aee…) — the
* READ-AFTER-WRITE seam: POST /:missionId/complete writes the mission
* README status, and the sidecar's 60s TTL would otherwise keep serving
* the pre-write word to /api/slices while /api/missions/:id reads the
* new word from disk. This drops the WHOLE missionStatusCache blob and
* nothing else (listing/detail caches untouched — drop-the-blob, never
* full-flush: zero coherence gain and a 60s registry churn were the
* ruling's reasons to reject the alternatives). The sole write path
* calls it post-success; the next read rebuilds from disk (README =
* SSOT). Out-of-band file writes remain the TTL regime by design. */
invalidateMissionStatusCache(): void {
this.missionStatusCache = null;
}
/** VM-005 (release-0.4.7) — the authored mission-status sidecar for the
* slices list payload: one mission-README frontmatter read per indexed
* mission, inside the same 60s cache discipline as the listing. Keys are
+5
View File
@@ -111,6 +111,11 @@ export function missionsRoutes(): Hono {
500,
);
}
// VM-005 B1 (narrow C-vii exception): the write succeeded — drop the
// authored-status sidecar cache so the very next /api/slices read serves
// the new word (read-after-write coherence). Any future daemon
// mission-status mutation route calls this same method.
indexer.invalidateMissionStatusCache();
return c.json({ missionId, status: "complete" });
});
@@ -17,6 +17,7 @@ import * as os from "node:os";
import * as path from "node:path";
import { SliceIndexer } from "../src/domain/slices/slice-indexer.js";
import { missionsRoutes } from "../src/routes/missions.js";
import { slicesRoutes } from "../src/routes/slices.js";
function buildApp(indexer: SliceIndexer): Hono {
const app = new Hono();
@@ -225,3 +226,71 @@ describe("POST /api/missions/:missionId/complete", () => {
expect(readme).toContain("id: preserves");
});
});
// ---------------------------------------------------------------------------
// VM-005 B1 — write-side cache coherence (the narrow C-vii exception;
// arch ruling b8d91aee…, plan v1.6.1 §J-1d, guard's repro shape verbatim).
// Out-of-band file writes remain the 60s TTL regime by design — this seam
// covers the daemon's OWN write path only (no watchers, no write-through).
// ---------------------------------------------------------------------------
type SidecarBody = { missions: Record<string, { authoredStatus: string | null }> };
function buildAppWithSlices(ix: SliceIndexer): Hono {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("sliceIndexer" as never, ix);
// the list path touches only the indexer; a stub satisfies getDeps
c.set("sliceDetailProjector" as never, {} as never);
await next();
});
app.route("/api/missions", missionsRoutes());
app.route("/api/slices", slicesRoutes());
return app;
}
describe("VM-005 B1 — read-after-write coherence at the daemon's own API", () => {
it("hot GET /api/slices → POST complete → IMMEDIATE detail + slices BOTH read complete; second POST idempotent", async () => {
writeMissionReadme(missionsRoot, "relx", "---\nid: relx\nstatus: active\n---\n# Relx\n");
writeSliceInMission(missionsRoot, "relx", "target");
const app = buildAppWithSlices(indexer);
// Prime the sidecar HOT — deliberately NO ?refresh=1 (refresh would
// full-invalidate and mask the seam; the defect lives on the hot path).
const primed = await app.request("/api/slices?filter=all");
expect(primed.status).toBe(200);
expect(((await primed.json()) as SidecarBody).missions["relx"]).toEqual({ authoredStatus: "active" });
const post = await app.request("/api/missions/relx/complete", { method: "POST" });
expect(post.status).toBe(200);
// IMMEDIATELY (no refresh, no TTL wait): both payloads carry the new word.
const detail = await app.request("/api/missions/relx");
expect(detail.status).toBe(200);
expect(((await detail.json()) as { status?: string | null }).status).toBe("complete");
const hot = await app.request("/api/slices?filter=all");
expect(((await hot.json()) as SidecarBody).missions["relx"]).toEqual({ authoredStatus: "complete" });
// Idempotent second POST: still 200, sidecar still coherent.
const again = await app.request("/api/missions/relx/complete", { method: "POST" });
expect(again.status).toBe(200);
const hot2 = await app.request("/api/slices?filter=all");
expect(((await hot2.json()) as SidecarBody).missions["relx"]).toEqual({ authoredStatus: "complete" });
});
it("NEGATIVE: listing + detail caches SURVIVE the complete-write invalidation (drop-the-blob, never full-flush)", async () => {
writeMissionReadme(missionsRoot, "relx", "---\nstatus: active\n---\n# Relx\n");
writeSliceInMission(missionsRoot, "relx", "target");
const app = buildAppWithSlices(indexer);
const listBefore = indexer.list(); // primes the listing cache
const recordBefore = indexer.get("target"); // primes the detail cache
indexer.missionAuthoredStatuses(); // primes the sidecar
const post = await app.request("/api/missions/relx/complete", { method: "POST" });
expect(post.status).toBe(200);
// Reference equality: the SAME cached instances = those caches survived.
expect(indexer.list()).toBe(listBefore);
expect(indexer.get("target")).toBe(recordBefore);
// ...while the sidecar rebuilt from disk with the new word.
expect(indexer.missionAuthoredStatuses()["relx"]).toEqual({ authoredStatus: "complete" });
});
});