fix(workflow): revert SliceWorkflowBinding nullability — drop unused declaration path

Forward-fix #1 on df79077. Guard re-review caught daemon tsc errors:
widening `SliceWorkflowBinding.instanceId` + `createdAt` to nullable
broke SliceDetailProjector at slice-detail-projector.ts:199 + 212
because the downstream projector + WorkflowBindingPayload contract
still expects non-null values.

Per guard option (a) recommendation: keep synthetic declaration
bindings OUT of SliceDetailProjector. The declaration-fallback path
was speculative — no caller actually passed the declaration param.
The missions route (the actual walk-item 7 acceptance surface) calls
`projectSpecGraph(row.spec, null)` DIRECTLY in routes/missions.ts;
it never went through findSliceWorkflowBinding.

Resolution:
- `SliceWorkflowBinding.instanceId` + `createdAt` back to non-null
  (preserves SliceDetailProjector + WorkflowBindingPayload contract).
- `findSliceWorkflowBinding` signature reverts to live-only:
  `(db, qitemIds) => SliceWorkflowBindingResult`. No declaration
  param, no synthetic binding path.
- 4 declaration-fallback tests removed from slice-workflow-binding.test.ts
  (the surface they tested no longer exists).

The walk-item-7 acceptance is unaffected — the mission Topology tab
renders the projected spec graph via the missions route's
projectSpecGraph(row.spec, null) call, which doesn't depend on the
synthetic-binding pathway.

If a future use case needs synthetic-binding-in-slice-detail (e.g.,
slice-scope Topology consumes declared workflow_spec), broaden the
WorkflowBindingPayload contract end-to-end (route + UI payload + tests)
in that slice rather than the partial widening this commit reverts.

Tests: 50/50 targeted (slice-indexer 32 + slice-workflow-binding 9 +
missions-routes 9). Daemon tsc clean. Full daemon suite: 3378 passed /
1 skipped / 0 failed on clean run. git diff --check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-11 07:37:06 -07:00
1 parent df79077f31
commit b6f3114d4c
2 files changed
+5 -110

No files matched your search

@@ -25,11 +25,8 @@
import type Database from "better-sqlite3";
export interface SliceWorkflowBinding {
/** The most recent workflow_instance touching the slice's qitem set.
* V0.3.1 slice 13: nullable when binding is synthetic (declaration-
* fallback path — slice/mission frontmatter declared workflow_spec
* but no live instance is bound). */
instanceId: string | null;
/** The most recent workflow_instance touching the slice's qitem set. */
instanceId: string;
workflowName: string;
workflowVersion: string;
status: string;
@@ -37,8 +34,7 @@ export interface SliceWorkflowBinding {
/** Frontier qitem_ids parsed from the JSON column. */
currentFrontier: string[];
hopCount: number;
/** ISO timestamp from the live instance row; null for synthetic bindings. */
createdAt: string | null;
createdAt: string;
completedAt: string | null;
}
@@ -60,29 +56,11 @@ interface InstanceRow {
completed_at: string | null;
}
/** V0.3.1 slice 13 walk-item 7 — frontmatter `workflow_spec: <name>@<version>`
* declaration. When passed AND no live instance binds the slice's
* qitems, findSliceWorkflowBinding returns a synthetic binding so
* the Topology projector can render the spec graph for a slice that
* has no live qitem activity yet. Live instances always win over
* declarations: declared metadata is a fallback, not an override. */
export interface WorkflowSpecDeclaration {
name: string;
version: string;
}
export function findSliceWorkflowBinding(
db: Database.Database,
qitemIds: string[],
declaration?: WorkflowSpecDeclaration | null,
): SliceWorkflowBindingResult {
// V0.3.1 slice 13 — empty-qitemIds path also honors declaration so
// mission-level callers (no qitem corpus) can still get a synthetic
// binding from their frontmatter declaration.
if (qitemIds.length === 0) {
if (declaration) return { primary: syntheticBinding(declaration), additionalInstanceIds: [] };
return { primary: null, additionalInstanceIds: [] };
}
if (qitemIds.length === 0) return { primary: null, additionalInstanceIds: [] };
const instanceIds = new Set<string>();
@@ -117,11 +95,7 @@ export function findSliceWorkflowBinding(
// workflow_instances absent — skip
}
// V0.3.1 slice 13 — declaration-fallback when no live instance binds.
if (instanceIds.size === 0) {
if (declaration) return { primary: syntheticBinding(declaration), additionalInstanceIds: [] };
return { primary: null, additionalInstanceIds: [] };
}
if (instanceIds.size === 0) return { primary: null, additionalInstanceIds: [] };
// Resolve full rows; sort by created_at DESC; pick most recent as primary.
const idList = Array.from(instanceIds);
@@ -147,25 +121,6 @@ export function findSliceWorkflowBinding(
return { primary, additionalInstanceIds: additional };
}
/** V0.3.1 slice 13 — synthetic binding for the frontmatter-declared
* workflow_spec when no live instance is bound. The projector reads
* `currentStepId` (null here) to skip "current step" highlighting;
* `instanceId: null` signals to the UI that this is a declared-only
* binding (no live instance). */
function syntheticBinding(declaration: WorkflowSpecDeclaration): SliceWorkflowBinding {
return {
instanceId: null,
workflowName: declaration.name,
workflowVersion: declaration.version,
status: "declared",
currentStepId: null,
currentFrontier: [],
hopCount: 0,
createdAt: null,
completedAt: null,
};
}
function rowToBinding(row: InstanceRow): SliceWorkflowBinding {
let frontier: string[] = [];
try {
@@ -187,64 +187,4 @@ describe("PL-slice-story-view-v1 findSliceWorkflowBinding", () => {
expect(result.primary?.instanceId).toBe("inst-both");
expect(result.additionalInstanceIds).toEqual([]);
});
// V0.3.1 slice 13 walk-item 7 — declaration-fallback path. When the
// slice/mission frontmatter declares `workflow_spec: <name>@<ver>`
// but no live instance touches the slice, the binding falls back to
// a synthetic record so the downstream projector can render the
// spec graph (currentStepId: null → projectSpecGraph(spec, null)).
describe("declaration-fallback binding (slice 13)", () => {
it("returns synthetic binding when declaration provided + no live instance", () => {
const result = findSliceWorkflowBinding(db, ["q-no-instance"], {
name: "openrig-velocity",
version: "1.0",
});
expect(result.primary).not.toBeNull();
expect(result.primary?.instanceId).toBeNull();
expect(result.primary?.workflowName).toBe("openrig-velocity");
expect(result.primary?.workflowVersion).toBe("1.0");
expect(result.primary?.currentStepId).toBeNull();
expect(result.primary?.currentFrontier).toEqual([]);
expect(result.primary?.status).toBe("declared");
expect(result.additionalInstanceIds).toEqual([]);
});
it("declaration is IGNORED when a live instance already binds (live signal wins)", () => {
insertInstance(db, {
instanceId: "inst-live",
currentFrontier: ["q-live"],
workflowName: "different-spec",
workflowVersion: "2.0",
});
insertTrail(db, {
trailId: "t-live",
instanceId: "inst-live",
stepId: "x",
stepRole: "r",
priorQitemId: "q-live",
});
const result = findSliceWorkflowBinding(db, ["q-live"], {
name: "openrig-velocity",
version: "1.0",
});
// Live wins; declaration metadata is not surfaced when a real
// instance is present.
expect(result.primary?.instanceId).toBe("inst-live");
expect(result.primary?.workflowName).toBe("different-spec");
});
it("no declaration AND no live instance → null binding (current behavior preserved)", () => {
const result = findSliceWorkflowBinding(db, ["q-no-instance"]);
expect(result.primary).toBeNull();
});
it("empty qitemIds + declaration provided → still returns synthetic binding (mission case)", () => {
const result = findSliceWorkflowBinding(db, [], {
name: "openrig-velocity",
version: "1.0",
});
expect(result.primary?.workflowName).toBe("openrig-velocity");
expect(result.primary?.instanceId).toBeNull();
});
});
});