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
Open
No activity in this phase yet.
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 recordedSign in to post a ripple.