Skip to main content
← Back to list
01Issue
BugOpenExtensionsPublicTeam
AssigneesNone

Relationships

#4575 software-factory: a work item whose slug extends another's reads the other's records

Opened by dr_evil · 11/10/2026

Extension: @swamp/software-factory 2026.10.23.1 (also on main of swamp-club/swamp-extensions at 2b12cfd)

What happens

loadRunView (extensions/models/_lib/run_data.ts) gathers a run's records by data-name prefix (artifact-<slug>-, evidence-<slug>-, validation-<slug>-, approval-<slug>-) and keys them by the record's own logical name. It never checks that the instance name is exactly <prefix><logical name>. When one work item's slug is another's plus -<suffix>, the longer item's records match the shorter item's prefixes. They overwrite the shorter item's records under the same logical name, so gates, bindings and status read the other run's data.

Work items x and x-revert: evidence-x-revert-live-result matches evidence-x-, and its envelope name live-result replaces x's own evidence-x-live-result. Approvals are replaced wholesale (view.approvals.set(records[0].gateId, ...)).

A malformed record of the longer item also throws inside the shorter item's load, because records are schema-parsed before anything checks ownership.

Impact seen

Run x was at stage live, cycle 2, with its own fresh live-result and live-signoff approval (both cycle 2). After x-revert recorded live-result and live-signoff in its own cycle 1, advance x close failed with:

  • evidence 'live-result' was recorded in stage 'live' cycle 1, not during the current entry into 'live' (cycle 2)
  • awaiting human approval 'live-signoff' (0/1) for stage 'live' cycle 2

Re-recording evidence or re-approving can't fix it: the foreign record wins every load. In our case it failed closed. A foreign record from the right stage and cycle (or a foreign approval) could equally satisfy a gate the run itself never met.

Writes, summary and physicalStepOutput use exact instance names, so the bug is confined to loadRunView.

Repro

memoryRepository in run_data_test.ts with evidence-wi-live-result (workItem wi, cycle 2) and evidence-wi-revert-live-result (workItem wi-revert, cycle 1): loadRunView(repo, t, m, "wi").evidence.get("live-result").latest.workItem === "wi-revert".

Suggested fix

In each branch of loadRunView, decode the record, then accept it only if instanceName === prefix + record[key] (name for artifacts and evidence, target for validations, gateId for approvals). Only then schema-parse it. This keeps names unambiguous without a format change. Patch with a regression test attached: all 180 tests pass, and deno task check and deno task lint are clean. Replayed against our real run data, it restores the stuck run's own records and its close gates pass, and it leaves every other run's view unchanged.

Workaround until fixed: don't name a work item <existing work item>-<suffix>.

Upstream repository: https://github.com/swamp-club/swamp-extensions

Environment

  • Extension: @swamp/software-factory@2026.10.23.1
  • swamp: 20261110.004355.0-sha.e171eaa8
  • OS: darwin (aarch64)
  • Deno: 2.9.8
  • Shell: /bin/zsh
02Bog Flow
◉OPEN○TRIAGED○IN PROGRESS○SHIPPED

Open

11/10/2026, 3:49:27 AM

No activity in this phase yet.

03Sludge Pulse
Editable. Press Enter to edit.

system commented 11/10/2026, 3:49:27 AM

Classified automatically when this issue was filed.

  • Source: Extensions

If you feel this classification is incorrect, add a ripple to tell us so.

dr_evil commented 11/10/2026, 3:50:47 AM

Patch for the suggested fix, against swamp-club/swamp-extensions main at 2b12cfd. It checks ownership in loadRunView before parsing, and adds a regression test in run_data_test.ts. With it applied: 180 tests pass, and deno task check, lint and fmt --check are clean.

diff --git a/software-factory/extensions/models/_lib/run_data.ts b/software-factory/extensions/models/_lib/run_data.ts
index 78c2344..c3c42f4 100644
--- a/software-factory/extensions/models/_lib/run_data.ts
+++ b/software-factory/extensions/models/_lib/run_data.ts
@@ -238,6 +238,24 @@ function decode(content: Uint8Array): Record<string, unknown> {
   >;
 }
 
+/**
+ * True when `instanceName` is exactly `prefix` + the record's own logical
+ * name (`record[key]`). A prefix match alone is not ownership: the slug
+ * "x-revert" starts with "x-", so "evidence-x-revert-plan" also matches the
+ * prefix "evidence-x-" but belongs to "x-revert". Checked before schema
+ * parsing so a foreign record can neither shadow this run's record nor fail
+ * this run's load.
+ */
+function ownsInstance(
+  instanceName: string,
+  prefix: string,
+  record: Record<string, unknown>,
+  key: string,
+): boolean {
+  const logical = record[key];
+  return typeof logical === "string" && instanceName === prefix + logical;
+}
+
 export async function loadRunView(
   repo: DataRepositoryLike,
   modelType: unknown,
@@ -301,8 +319,9 @@ export async function loadRunView(
         name,
         latestVersion,
       );
-      if (content !== null) {
-        const envelope = ArtifactEnvelopeSchema.parse(decode(content));
+      const raw = content === null ? null : decode(content);
+      if (raw !== null && ownsInstance(name, artifactPrefix, raw, "name")) {
+        const envelope = ArtifactEnvelopeSchema.parse(raw);
         view.artifacts.set(envelope.name, {
           latest: envelope,
           version: latestVersion,
@@ -318,8 +337,9 @@ export async function loadRunView(
         name,
         latestVersion,
       );
-      if (content !== null) {
-        const envelope = EvidenceEnvelopeSchema.parse(decode(content));
+      const raw = content === null ? null : decode(content);
+      if (raw !== null && ownsInstance(name, evidencePrefix, raw, "name")) {
+        const envelope = EvidenceEnvelopeSchema.parse(raw);
         view.evidence.set(envelope.name, {
           latest: envelope,
           version: latestVersion,
@@ -335,8 +355,9 @@ export async function loadRunView(
         name,
         latestVersion,
       );
-      if (content !== null) {
-        const envelope = ValidationEnvelopeSchema.parse(decode(content));
+      const raw = content === null ? null : decode(content);
+      if (raw !== null && ownsInstance(name, validationPrefix, raw, "target")) {
+        const envelope = ValidationEnvelopeSchema.parse(raw);
         view.validations.set(envelope.target, {
           latest: envelope,
           version: latestVersion,
@@ -362,8 +383,9 @@ export async function loadRunView(
           name,
           version,
         );
-        if (content !== null) {
-          records.push(ApprovalRecordSchema.parse(decode(content)));
+        const raw = content === null ? null : decode(content);
+        if (raw !== null && ownsInstance(name, approvalPrefix, raw, "gateId")) {
+          records.push(ApprovalRecordSchema.parse(raw));
         }
       }
       if (records.length > 0) {
diff --git a/software-factory/extensions/models/_lib/run_data_test.ts b/software-factory/extensions/models/_lib/run_data_test.ts
index 598a6cf..cc4adf7 100644
--- a/software-factory/extensions/models/_lib/run_data_test.ts
+++ b/software-factory/extensions/models/_lib/run_data_test.ts
@@ -174,6 +174,83 @@ Deno.test("loadRunView: scoped to one work item's slug-namespaced records", asyn
   assertEquals(other.approvals.size, 0);
 });
 
+Deno.test("loadRunView: a work item whose slug extends another's does not shadow it", async () => {
+  // "wi-revert" starts with "wi-", so its
+  // records also match the shorter item's name prefixes. They must not be
+  // read as the shorter item's records.
+  const evidence = (workItem: string, cycle: number, at: string) => ({
+    name: "live-result",
+    workItem,
+    stageId: "live",
+    cycle,
+    payload: { driftClean: true },
+    recordedAt: at,
+  });
+  const approval = (workItem: string, cycle: number, at: string) => ({
+    gateId: "live-signoff",
+    workItem,
+    decision: "approved",
+    actor: "owner",
+    stageId: "live",
+    cycle,
+    decidedAt: at,
+  });
+  const repo = memoryRepository({
+    "state-wi": [state("wi", { stageId: "live", cycles: { live: 2 } })],
+    "state-wi-revert": [
+      state("wi-revert", { stageId: "live", cycles: { live: 1 } }),
+    ],
+    "evidence-wi-live-result": [
+      evidence("wi", 1, "2026-06-11T10:01:00.000Z"),
+      evidence("wi", 2, "2026-06-11T10:02:00.000Z"),
+    ],
+    "evidence-wi-revert-live-result": [
+      evidence("wi-revert", 1, "2026-06-11T10:03:00.000Z"),
+    ],
+    "approval-wi-live-signoff": [approval("wi", 2, "2026-06-11T10:04:00.000Z")],
+    "approval-wi-revert-live-signoff": [
+      approval("wi-revert", 1, "2026-06-11T10:05:00.000Z"),
+    ],
+    "artifact-wi-revert-plan": [
+      artifact("wi-revert", "plan", 1, { v: "revert" }),
+    ],
+    "validation-wi-revert-plan": [
+      {
+        target: "plan",
+        targetKind: "artifact",
+        workItem: "wi-revert",
+        stageId: "live",
+        cycle: 1,
+        attempt: 1,
+        cleared: false,
+        errors: ["x"],
+        recordedAt: "2026-06-11T10:06:00.000Z",
+      },
+    ],
+  });
+
+  const view = await loadRunView(repo, "type", "id", "wi");
+  assertEquals(view.evidence.get("live-result")?.latest.workItem, "wi");
+  assertEquals(view.evidence.get("live-result")?.latest.cycle, 2);
+  assertEquals(view.evidence.get("live-result")?.version, 2);
+  const approvals = view.approvals.get("live-signoff");
+  assertEquals(approvals?.length, 1);
+  assertEquals(approvals?.[0].workItem, "wi");
+  assertEquals(approvals?.[0].cycle, 2);
+  assertEquals(view.artifacts.size, 0);
+  assertEquals(view.validations.size, 0);
+
+  // The longer slug still sees exactly its own records.
+  const revert = await loadRunView(repo, "type", "id", "wi-revert");
+  assertEquals(
+    revert.evidence.get("live-result")?.latest.workItem,
+    "wi-revert",
+  );
+  assertEquals(revert.approvals.get("live-signoff")?.[0].workItem, "wi-revert");
+  assertEquals(revert.artifacts.get("plan")?.latest.payload, { v: "revert" });
+  assertEquals(revert.validations.size, 1);
+});
+
 Deno.test("loadRunView: era filtering hides records from before a reset", async () => {
   const repo = memoryRepository({
     // startedAt is AFTER the artifact and the first approval were recorded

Sign in to post a ripple.