From b4ed12e9c86b7349dfaaa325fbf9e3913a87e7d5 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 30 Jul 2026 19:07:09 -0700 Subject: [PATCH] batch-u7-lane-fixes: three core/engine renamed-board fixes folded (was #2925, #2930, #2936) (#2925) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Consolidated per the queue freeze.** Three single-fix PRs of mine folded into this one branch; #2930 and #2936 are closed as superseded. Net effect on the queue: **3 → 1**. All three are the same root cause — a lifecycle lane compared against a legacy id — and all three carry a measured revert proof. Verified scoped (not full suite) on the folded branch: `tsc --noEmit` clean, `pnpm lint` clean, SQL-literal gate green, census `--strict` green, and 61 tests across five suites plus the guard at 9/9. --- ### 1. `getLiveTaskColumn` produced the archived sentinel from a literal (was #2925) `getLiveTaskColumn` **manufactures** the string `"archived"` that a dozen comparisons across five files trust — and it tested `row.column === "archived"`. A live row in a renamed archived lane read as **live**, so the gates hiding an archived card's artifacts and document listings never closed. Fixing those twelve comparisons individually would have been wrong twice over: **they are sentinels, and the defect was in the producer.** One line, once, and all twelve become correct. `resolveArchivedLanes` moved to `project-lane-vocabulary.ts` — three private copies of one fact is how the "write guard says yes, publication guard says no" disagreement happens at scale. *Revert proof (real PostgreSQL):* restore the literal → `expected [ { …(14) } ] to deeply equal []`. **Caught myself shipping the unwired shape here:** I added the parameter to seven functions and wired none of their impl callers — the exact inert-conversion defect this program exists to remove. The failing test is the only reason I noticed. ### 2. Mission delivery repair refused a completed card (was #2930) `getTerminalTaskEvidence` tested only `column === "done"`, so a completed card on a renamed board classified as `nonterminal` and `reconcileFeatureDoneWithTerminalTask` threw `TASK_NOT_TERMINAL: … not shipped`. Valid operator work refused — with the message naming the real column while the check couldn't see it. The **type** blocked the fix from the far end: `TerminalTaskEvidence` pinned `column: "done"` / `"archived"`, so the resolver couldn't report the real column without a compile error. `kind` already carries the role, so `column` is free to carry the truth. *Revert proof (real PostgreSQL):* restore the literal → `TerminalTaskReconciliationError: … not shipped`. I had deferred this twice on the premise that `AsyncMissionStore` "holds a layer, not a store". It holds an **optional `taskStore`**, and the single production construction site supplies it. ### 3. The unwired-lane guard reported two FALSE entries (was #2936) `unwired-lane-parameter-guard.test.ts` has been **red on main** since #2875, flagging two `InReviewDurationLanes` properties as unwired when the impl demonstrably supplies both. Cause: my own owner-scoping rule requires a mention from a file naming the declaring symbol — correct for a function, structurally impossible for an interface passed as an inferred object literal. Fixed at the caller (name the type) after trying the tool three ways: relaxing type-owned properties hid **12** genuine entries; resolving owners to consuming functions hid **6**. Each refinement traded the false positive for false negatives — the sign a co-occurrence heuristic has hit its limit. Recording two *wired* parameters in `KNOWN_UNWIRED` was rejected: that puts non-debt in the debt list, which is how a ratchet starts lying. Guard back to **9/9**, baseline unchanged at 17. **This un-reds main.** 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) --- .changeset/live-task-column-lanes.md | 7 +++ .changeset/terminal-evidence-lanes.md | 7 +++ .../archived-document-lanes.pg.test.ts | 42 +++++++++++++++ .../postgres/mission-store.pg.test.ts | 48 +++++++++++++++++ .../core/src/async-mission-store-queries.ts | 43 ++++++++++++--- packages/core/src/async-mission-store.ts | 22 +++++++- packages/core/src/index.gate.ts | 2 +- packages/core/src/index.ts | 2 +- packages/core/src/project-lane-vocabulary.ts | 26 +++++++++ .../task-store/async-comments-attachments.ts | 54 +++++++++++++------ packages/core/src/task-store/audit-ops.ts | 3 +- packages/core/src/task-store/comments-ops.ts | 18 +++---- .../core/src/task-store/task-artifacts-ops.ts | 17 +++--- .../core/src/task-store/task-id-integrity.ts | 3 +- .../core/src/task-store/task-mutation-ops.ts | 3 +- .../src/task-store/workflow-definitions.ts | 18 ++++++- .../lib/lifecycle-column-census-baseline.json | 2 +- 17 files changed, 269 insertions(+), 48 deletions(-) create mode 100644 .changeset/live-task-column-lanes.md create mode 100644 .changeset/terminal-evidence-lanes.md diff --git a/.changeset/live-task-column-lanes.md b/.changeset/live-task-column-lanes.md new file mode 100644 index 0000000000..98943c55f6 --- /dev/null +++ b/.changeset/live-task-column-lanes.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: A card in a renamed archived lane is now recognised as archived everywhere, not just on two paths. +category: fix +dev: `getLiveTaskColumn` manufactures the sentinel `"archived"` that a dozen comparisons across five files trust, and it tested `row.column === "archived"` — so a live row in a renamed archived lane read as live and every downstream gate opened. It now takes a resolved archived-lane set, threaded from the store-level impls; the shared `resolveArchivedLanes` moved to `project-lane-vocabulary.ts` so there is one answer rather than three copies. diff --git a/.changeset/terminal-evidence-lanes.md b/.changeset/terminal-evidence-lanes.md new file mode 100644 index 0000000000..aa86b403f6 --- /dev/null +++ b/.changeset/terminal-evidence-lanes.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Mission delivery repair now accepts a completed card on boards that rename the done lane. +category: fix +dev: `getTerminalTaskEvidence` tested only `column === "done"`, so a completed card on a renamed board classified as `nonterminal` and `reconcileFeatureDoneWithTerminalTask` threw `TASK_NOT_TERMINAL`. It now takes resolved complete/archived lane sets, supplied by `AsyncMissionStore` from its `taskStore`. The `TerminalTaskEvidence` type's `column` field was widened from the pinned literals to `string`. diff --git a/packages/core/src/__tests__/postgres/archived-document-lanes.pg.test.ts b/packages/core/src/__tests__/postgres/archived-document-lanes.pg.test.ts index 781ab6f0d3..5ba56a09a8 100644 --- a/packages/core/src/__tests__/postgres/archived-document-lanes.pg.test.ts +++ b/packages/core/src/__tests__/postgres/archived-document-lanes.pg.test.ts @@ -104,6 +104,48 @@ pgDescribe("archived-document guards resolve the board's archived lane", () => { ).rejects.toThrow(/read-only/); }); + /* + FNXC:WorkflowLifecycleColumns 2026-07-31-04:10: + THE SENTINEL ITSELF, not just the two guards that read a row directly. + + #2886 fixed `upsertTaskDocument` and `publishArchivedTaskDocumentAddition`, which read `task.column` + from their own `select`. Everything ELSE in this area routes through `getLiveTaskColumn`, which + MANUFACTURES the string "archived" — a dozen comparisons across five files trust it. Keyed on the + literal, that function reported a live row in a renamed archived lane as LIVE, so the gates that + hide an archived card's artifacts and document LISTINGS never closed. + + Fixing those dozen comparisons individually would have been wrong twice over: they are sentinels, + and the defect was in the producer. + + ARTIFACTS, not `getTaskDocument`. My first version asserted that reading a document returns + undefined for an archived card, and it failed — `getTaskDocument` gates on `column === null`, i.e. + existence only. Reading an archived card's document is ALLOWED by design; the read-only contract is + about writes. The premise was wrong, not the code, which is the fourth time this session that + suspecting my own fixture first would have been quicker. `getArtifacts` is one of the five sites + that genuinely consumes the sentinel (`column === null || column === "archived"`). + + REVERT PROOF, measured: restore `row.column === "archived"` in `getLiveTaskColumn` and this fails — + the artifact list is returned for a card the board shows as archived. + */ + it("hides artifacts behind the SENTINEL for a card in a renamed archived lane", async () => { + const store = h.store(); + const task = await store.createTask({ description: "Archived into a renamed lane, then listed" }); + await store.registerArtifact({ + type: "document", + title: "spec", + description: "inline body", + content: "body", + authorId: "agent-1", + authorType: "agent", + taskId: task.id, + }); + expect(await store.getArtifacts(task.id)).toHaveLength(1); + + await parkInRenamedArchivedLane(task.id); + + expect(await store.getArtifacts(task.id)).toEqual([]); + }); + it("does NOT treat a live card as archived just because a lane is named vault", async () => { // The guard must still let real work through — rejecting everything would be its own bug. const store = h.store(); diff --git a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts index 5f53502939..d0a24f6930 100644 --- a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts +++ b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts @@ -397,6 +397,54 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => { FNXC:MissionReconciliation 2026-07-20-08:34: Regression coverage exercises every terminal-evidence representation through the real PostgreSQL store. Reconciliation must never route through ordinary triage linking, mutate loop attempts or mission controls, or partially commit when the transaction fails. */ + /* + FNXC:WorkflowLifecycleColumns 2026-07-31-05:05: + THE INVARIANT: terminal evidence is the card's ROLE, not the id `done`. + + `getTerminalTaskEvidence` tested only `column === "done"`, so a genuinely completed card on a + renamed board fell through every branch to `nonterminal` and this method threw + `TASK_NOT_TERMINAL: ... must be in done or supported archived state, not shipped`. Mission + shipped-delivery repair refused valid work — and the message named the real column while the check + could not see it, which is the tell that the classifier and the reporter disagreed. + + I deferred this twice on the premise that `AsyncMissionStore` "holds a layer, not a store". It + holds an OPTIONAL `taskStore`, and the single production construction site supplies it. That is the + third deferral of mine this session to dissolve on inspection, which is the argument for checking a + premise before recording it as a blocker. + + REVERT PROOF, measured: restore `column === "done"` and this fails with a + `TASK_NOT_TERMINAL` rejection naming `shipped`. + */ + it("accepts a completed card whose board calls the lane something else", async () => { + const m = missions(); + const store = h.store(); + await store.createWorkflowDefinition({ + name: "Renamed complete", + ir: { + version: "v2", + name: "Renamed complete", + columns: [ + { id: "todo", name: "Todo", traits: [{ trait: "intake" }, { trait: "hold" }] }, + { id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "todo" }, + { id: "end", kind: "end", column: "shipped" }, + ], + edges: [{ from: "start", to: "end", condition: "success" }], + } as never, + }); + const mission = await m.createMission({ title: "Renamed-lane repair" }); + const milestone = await m.addMilestone(mission.id, { title: "MS" }); + const slice = await m.addSlice(milestone.id, { title: "SL" }); + const feature = await m.addFeature(slice.id, { title: "Delivered" }); + const task = await store.createTask({ description: "shipped elsewhere", column: "shipped" as never }); + + const reconciled = await m.reconcileFeatureDoneWithTerminalTask(feature.id, task.id); + + expect(reconciled).toMatchObject({ taskId: task.id, status: "done" }); + }); + it("atomically reconciles live done evidence and remains idempotent", async () => { const m = missions(); const mission = await m.createMission({ title: "Parked repair" }); diff --git a/packages/core/src/async-mission-store-queries.ts b/packages/core/src/async-mission-store-queries.ts index f215fd8949..1ef14e7c0b 100644 --- a/packages/core/src/async-mission-store-queries.ts +++ b/packages/core/src/async-mission-store-queries.ts @@ -2173,9 +2173,19 @@ export async function listLiveLinkedTaskIds(handle: QueryHandle, taskIds: string return new Set(rows.map((row) => row.id)); } +/* +FNXC:WorkflowLifecycleColumns 2026-07-31-05:05: +`column` is the lane the card ACTUALLY reached, not the built-in name for its role. + +These two arms pinned `"done"` and `"archived"` in the TYPE, which is the census-invisible shape that +blocks a conversion from the other end: the resolver below could not report the real column without a +compile error, so the type would have forced the literal back in even after the predicate was fixed. +`kind` already carries the role — that is what a consumer switches on — so the column field is free to +carry the truth. +*/ export type TerminalTaskEvidence = - | { kind: "done"; id: string; column: "done" } - | { kind: "archived"; id: string; column: "archived" } + | { kind: "done"; id: string; column: string } + | { kind: "archived"; id: string; column: string } | { kind: "nonterminal"; id: string; column: string } | { kind: "invalid-deleted"; id: string; column?: string } | { kind: "missing" }; @@ -2184,7 +2194,11 @@ export type TerminalTaskEvidence = * FNXC:MissionReconciliation 2026-07-20-08:34: * Terminal evidence repair must distinguish a supported archive (the retained archived task tombstone plus its project-scoped cold snapshot) from an arbitrary soft/hard deletion. Read both representations on the caller's transaction handle so validation and feature linkage share one snapshot. */ -export async function getTerminalTaskEvidence(handle: QueryHandle, taskId: string): Promise { +export async function getTerminalTaskEvidence( + handle: QueryHandle, + taskId: string, + terminalColumns?: { complete?: ReadonlySet; archived?: ReadonlySet }, +): Promise { const taskRows = await handle .select({ id: schema.project.tasks.id, @@ -2218,12 +2232,27 @@ export async function getTerminalTaskEvidence(handle: QueryHandle, taskId: strin set threaded in by the caller — the same shape `getLiveTaskColumn` needs, and it should land with it so the two cannot disagree about what "finished" means. */ + /* + FNXC:WorkflowLifecycleColumns 2026-07-31-05:05: + The lane sets arrive from the caller; omitted, the legacy ids answer exactly as before. + + I deferred this twice on the premise that `AsyncMissionStore` "holds a layer, not a store" and so + could not resolve anything. It holds an OPTIONAL `taskStore`, and the single production construction + site supplies it (`workflow-definitions.ts`: `new AsyncMissionStore(layer, store)`). That is the + third deferral of mine to dissolve on inspection, which is why the premise is now recorded next to + the fix rather than in a note claiming it cannot be done. + */ + const isComplete = (column: string) => + terminalColumns?.complete ? terminalColumns.complete.has(column) : column === "done"; + const isArchived = (column: string) => + terminalColumns?.archived ? terminalColumns.archived.has(column) : column === "archived"; + if (!task) return hasArchiveSnapshot ? { kind: "invalid-deleted", id: taskId } : { kind: "missing" }; - if (task.deletedAt === null && task.column === "done") return { kind: "done", id: task.id, column: "done" }; - if (task.deletedAt !== null && task.column === "archived" && hasArchiveSnapshot) { - return { kind: "archived", id: task.id, column: "archived" }; + if (task.deletedAt === null && isComplete(task.column)) return { kind: "done", id: task.id, column: task.column }; + if (task.deletedAt !== null && isArchived(task.column) && hasArchiveSnapshot) { + return { kind: "archived", id: task.id, column: task.column }; } - if (task.deletedAt !== null || task.column === "archived") { + if (task.deletedAt !== null || isArchived(task.column)) { return { kind: "invalid-deleted", id: task.id, column: task.column }; } return { kind: "nonterminal", id: task.id, column: task.column }; diff --git a/packages/core/src/async-mission-store.ts b/packages/core/src/async-mission-store.ts index 4878a7a0d5..1ce9f95d6c 100644 --- a/packages/core/src/async-mission-store.ts +++ b/packages/core/src/async-mission-store.ts @@ -52,6 +52,7 @@ import type { Goal } from "./goal-types.js"; import { deriveMilestoneAcceptanceCriteriaFromFeatures, } from "./mission-store.js"; +import { resolveProjectColumnsForRoles } from "./project-lane-vocabulary.js"; import type { MissionSummary, MissionAssertionBackfillReport, @@ -1158,7 +1159,26 @@ export class AsyncMissionStore extends EventEmitter { ); } - const evidence = await getTerminalTaskEvidence(tx, taskId); + /* + FNXC:WorkflowLifecycleColumns 2026-07-31-05:05: + Resolve the board's terminal lanes and hand them down; without this the predicate is inert. + + Keyed on the literals, a genuinely completed card on a renamed board fell through every branch + to `nonterminal`, and this method then threw `TASK_NOT_TERMINAL: ... must be in done or + supported archived state, not shipped`. Mission shipped-delivery repair refused valid work, and + the message named the real column while the check could not see it. + + `this.taskStore` is optional on the class but the single production construction site supplies + it (`workflow-definitions.ts`). Absent, the resolver is skipped and the legacy ids answer — + which is what every test that constructs the store without one already relies on. + */ + const terminalColumns = this.taskStore + ? { + complete: await resolveProjectColumnsForRoles(this.taskStore, ["complete"]).catch(() => undefined), + archived: await resolveProjectColumnsForRoles(this.taskStore, ["archived"]).catch(() => undefined), + } + : undefined; + const evidence = await getTerminalTaskEvidence(tx, taskId, terminalColumns); if (evidence.kind === "missing") { throw new TerminalTaskReconciliationError("TASK_NOT_FOUND", `Delivery task ${taskId} not found`); } diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 8ababbe906..403beeea97 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -2265,7 +2265,7 @@ file (`scripts/build-engine-core-gate-bundle.mjs`), so an export added only to ` an import error. That cost 88 red tests in `project-engine.test.ts`, all with the same misleading "columns is not iterable" a hundred lines from the actual cause. */ -export { resolveProjectColumnsForRoles, REVIEW_ROLES, TERMINAL_ROLES, LEGACY_COLUMN_IDS_BY_ROLE, type ProjectLaneVocabularyStore } from "./project-lane-vocabulary.js"; +export { resolveProjectColumnsForRoles, resolveArchivedLanes, REVIEW_ROLES, TERMINAL_ROLES, LEGACY_COLUMN_IDS_BY_ROLE, type ProjectLaneVocabularyStore } from "./project-lane-vocabulary.js"; export type { LifecycleColumns } from "./workflow-lifecycle-traits.js"; export { resolveReviewLevelSteps, applyReviewLevelPreset } from "./review-level-preset.js"; export { LEGACY_STATUS_ADOPTION, resolveLegacyStatusAdoption, resolveReviewLevelBackfill, planLegacyAdoption, resolveOrphanedPendingStepResults, type LegacyAdoptionPlan, type LegacyAdoptionCandidate, type LegacyAdoptionAction, type LegacyAdoptionKind } from "./legacy-adoption.js"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6951a0a771..0aacdacfb6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -469,7 +469,7 @@ export { findWorkflowEventShapeViolations, isIdsOnlyWorkflowEvent, MAX_ID_VALUE_ export type { WorkflowLifecycleEvent, WorkflowLifecycleEventType, WorkflowLifecycleEventBase, TaskTransitionedEvent, NodeEnteredEvent, NodeCompletedEvent, RunSuspendedEvent, RunResumedEvent, WorkflowEventShapeViolation, ImplementationExit } from "./types/workflow-events.js"; export { columnHasFlag, columnsWithFlag, declaresAnyLifecycleTrait, resolveArchiveTargetForTask, resolveCompleteColumn, resolveLifecycleColumns, resolveMergeOrchestrationColumn, resolveReboundTarget, resolveReboundTargetForTask, resolveReviewColumns, resolveTaskLifecycleColumns, resolveTerminalColumns, resolveWipTargetForTask } from "./workflow-lifecycle-traits.js"; export type { LifecycleColumns } from "./workflow-lifecycle-traits.js"; -export { resolveProjectColumnsForRoles, REVIEW_ROLES, TERMINAL_ROLES, LEGACY_COLUMN_IDS_BY_ROLE, type ProjectLaneVocabularyStore } from "./project-lane-vocabulary.js"; +export { resolveProjectColumnsForRoles, resolveArchivedLanes, REVIEW_ROLES, TERMINAL_ROLES, LEGACY_COLUMN_IDS_BY_ROLE, type ProjectLaneVocabularyStore } from "./project-lane-vocabulary.js"; export { resolveReviewLevelSteps, applyReviewLevelPreset } from "./review-level-preset.js"; export { LEGACY_STATUS_ADOPTION, diff --git a/packages/core/src/project-lane-vocabulary.ts b/packages/core/src/project-lane-vocabulary.ts index 2a720f892c..7e3fea8679 100644 --- a/packages/core/src/project-lane-vocabulary.ts +++ b/packages/core/src/project-lane-vocabulary.ts @@ -130,3 +130,29 @@ export const REVIEW_ROLES = ["mergeOrchestration", "mergeBlocker", "humanReview" /** "Finished either way" — the pair `resolveTerminalColumns` answers for a single task. */ export const TERMINAL_ROLES = ["complete", "archived"] as const; + +/* +FNXC:WorkflowLifecycleColumns 2026-07-31-04:10: +ONE archived-lane answer, shared, because the alternative is callers disagreeing. + +`getLiveTaskColumn` manufactures the string "archived" for an archived-or-soft-deleted parent, and a +dozen call sites across five files compare against that sentinel. The sentinel is only correct if the +function that PRODUCES it recognises the board's archived lane — keyed on the literal it does not, so +on a renamed board a live row in `vault` is reported as live and every downstream gate opens. + +Fixing that means every caller supplies the same lane set. `comments-ops.ts` grew a private copy of +this helper with #2886; a second copy is how two readers of one fact start to disagree, which is the +mistake `resolveProjectColumnsForRoles`' own header warns about. Promoted here so there is one. + +Best-effort by contract: an unresolvable workflow returns undefined and every caller falls back to +the legacy id, which is exactly the behaviour before any of this. +*/ +export async function resolveArchivedLanes( + store: ProjectLaneVocabularyStore, +): Promise | undefined> { + try { + return await resolveProjectColumnsForRoles(store, ["archived"]); + } catch { + return undefined; + } +} diff --git a/packages/core/src/task-store/async-comments-attachments.ts b/packages/core/src/task-store/async-comments-attachments.ts index ec52906d55..e6cc782d7e 100644 --- a/packages/core/src/task-store/async-comments-attachments.ts +++ b/packages/core/src/task-store/async-comments-attachments.ts @@ -120,10 +120,26 @@ function rowToArtifact(row: ArtifactRow): Artifact { * or soft-deleted tasks. Returns the task's column if live, or `null` if the * task is absent, archived, or soft-deleted. */ +/* +FNXC:WorkflowLifecycleColumns 2026-07-31-04:10: +The sentinel is only as correct as the lane test that PRODUCES it. + +A dozen call sites across five files compare this function's result against the string "archived", +and every one of those comparisons is right precisely because this function manufactures it. Keyed on +the literal, a live row in a renamed archived lane (`vault`, not soft-deleted) was reported as LIVE — +so documents stayed readable and writable, artifacts listed, and log writes were accepted on a card +the board shows as archived. Fixing the twelve comparisons individually would have been wrong twice +over: they are sentinels, and the defect is here. + +`archivedColumns` is threaded from the store-level impls, which are the only layer that can resolve +it — this function holds a `db` handle. Omitted, the legacy id answers, which is the behaviour every +caller had before. +*/ export async function getLiveTaskColumn( db: AsyncDataLayer["db"] | DbTransaction, taskId: string, projectId?: string, + archivedColumns?: ReadonlySet, ): Promise { /* FNXC:PostgresArchiveSafety 2026-07-14-21:48: @@ -139,7 +155,8 @@ export async function getLiveTaskColumn( .limit(1); const row = rows[0]; if (!row) return null; - if (row.column === "archived" || row.deletedAt != null) return "archived"; + const isArchivedLane = archivedColumns ? archivedColumns.has(row.column) : row.column === "archived"; + if (isArchivedLane || row.deletedAt != null) return "archived"; return row.column; } @@ -183,8 +200,9 @@ export async function getTaskDocument( taskId: string, key: string, projectId?: string, -): Promise { - const column = await getLiveTaskColumn(db, taskId, projectId); + + archivedColumns?: ReadonlySet,): Promise { + const column = await getLiveTaskColumn(db, taskId, projectId, archivedColumns); if (column === null) return null; const rows = await db @@ -463,8 +481,9 @@ export async function listTaskDocuments( db: AsyncDataLayer["db"] | DbTransaction, taskId: string, projectId?: string, -): Promise { - const column = await getLiveTaskColumn(db, taskId, projectId); + + archivedColumns?: ReadonlySet,): Promise { + const column = await getLiveTaskColumn(db, taskId, projectId, archivedColumns); if (column === null || column === "archived") return []; const rows = await db @@ -486,8 +505,9 @@ export async function getTaskDocumentRevisions( taskId: string, key: string, projectId?: string, -): Promise { - const column = await getLiveTaskColumn(db, taskId, projectId); + + archivedColumns?: ReadonlySet,): Promise { + const column = await getLiveTaskColumn(db, taskId, projectId, archivedColumns); if (column === null) return []; const rows = await db @@ -522,9 +542,10 @@ export async function deleteTaskDocument( layer: AsyncDataLayer, taskId: string, key: string, -): Promise { + + archivedColumns?: ReadonlySet,): Promise { return layer.transactionImmediate(async (tx) => { - const state = await getLiveTaskColumn(tx, taskId, layer.projectId); + const state = await getLiveTaskColumn(tx, taskId, layer.projectId, archivedColumns); if (state === "archived") throw new Error(`Task ${taskId} is archived — documents are read-only`); if (state === null) throw new Error(`Task ${taskId} not found`); const existing = await tx @@ -580,11 +601,12 @@ export async function insertArtifactRow( layer: AsyncDataLayer, input: ArtifactCreateInput, stored: { uri?: string; sizeBytes?: number }, -): Promise { + + archivedColumns?: ReadonlySet,): Promise { return layer.transactionImmediate(async (tx) => { // Gate: if taskId is set, the parent must be live. if (input.taskId) { - const column = await getLiveTaskColumn(tx, input.taskId, layer.projectId); + const column = await getLiveTaskColumn(tx, input.taskId, layer.projectId, archivedColumns); if (column === "archived") { throw new Error(`Task ${input.taskId} is archived — artifacts are read-only`); } @@ -634,14 +656,15 @@ export async function updateArtifactRow( layer: AsyncDataLayer, id: string, updates: { title?: string; description?: string; content?: string }, -): Promise { + + archivedColumns?: ReadonlySet,): Promise { return layer.transactionImmediate(async (tx) => { const existing = await getArtifact(tx, id); if (!existing) { throw new Error(`Artifact ${id} not found`); } if (existing.taskId) { - const column = await getLiveTaskColumn(tx, existing.taskId, layer.projectId); + const column = await getLiveTaskColumn(tx, existing.taskId, layer.projectId, archivedColumns); if (column === "archived") { throw new Error(`Task ${existing.taskId} is archived — artifacts are read-only`); } @@ -698,8 +721,9 @@ export async function getArtifacts( db: AsyncDataLayer["db"] | DbTransaction, taskId: string, projectId?: string, -): Promise { - const column = await getLiveTaskColumn(db, taskId, projectId); + + archivedColumns?: ReadonlySet,): Promise { + const column = await getLiveTaskColumn(db, taskId, projectId, archivedColumns); if (column === null || column === "archived") return []; const rows = await db diff --git a/packages/core/src/task-store/audit-ops.ts b/packages/core/src/task-store/audit-ops.ts index 332d39facf..a09ce6dd35 100644 --- a/packages/core/src/task-store/audit-ops.ts +++ b/packages/core/src/task-store/audit-ops.ts @@ -17,6 +17,7 @@ import "../builtin-traits.js"; import {__setTaskActivityLogLimitsForTesting, truncateTaskLogOutcome, getTaskActivityLogEntryLimit} from "../task-store/comments.js"; import {readTaskRow, updateTaskColumns} from "../task-store/async-persistence.js"; import { getLiveTaskColumn } from "./async-comments-attachments.js"; +import { resolveArchivedLanes } from "../project-lane-vocabulary.js"; export async function runPluginColumnTransitionHooksImpl(store: TaskStore, taskId: string, workflowIr: WorkflowIr, fromColumn: string, toColumn: string,): Promise { const registry = getTraitRegistry(); @@ -123,7 +124,7 @@ export async function logEntryImpl(store: TaskStore, id: string, action: string, if (runContext) { { const layer = store.asyncLayer!; - const state = await getLiveTaskColumn(layer.db, id, layer.projectId); + const state = await getLiveTaskColumn(layer.db, id, layer.projectId, await resolveArchivedLanes(store)); /* FNXC:WorkflowLifecycleColumns 2026-07-30-21:20 (audited — SENTINEL, do NOT convert): `getLiveTaskColumn` MANUFACTURES the string "archived" for an archived-or-soft-deleted diff --git a/packages/core/src/task-store/comments-ops.ts b/packages/core/src/task-store/comments-ops.ts index 0808c65bc2..d32aeb33da 100644 --- a/packages/core/src/task-store/comments-ops.ts +++ b/packages/core/src/task-store/comments-ops.ts @@ -21,7 +21,7 @@ import "../builtin-traits.js"; import {resolveWorkflowIrForTask} from "../workflow-ir-resolver.js"; import {resolveLifecycleColumns} from "../workflow-lifecycle-traits.js"; import {__setTaskActivityLogLimitsForTesting, isBootstrapPromptStub} from "../task-store/comments.js"; -import { resolveProjectColumnsForRoles } from "../project-lane-vocabulary.js"; +import { resolveArchivedLanes } from "../project-lane-vocabulary.js"; import {getLiveTaskColumn, publishArchivedTaskDocumentAddition as publishArchivedTaskDocumentAdditionAsync, upsertTaskDocument as upsertTaskDocumentAsync} from "../task-store/async-comments-attachments.js"; /* @@ -61,7 +61,7 @@ export function resolvePostCommentRetriageDecision(input: { export async function addCommentImpl(store: TaskStore, id: string, text: string, author: string = "user", options?: { skipRefinement?: boolean; source?: "user" | "agent" | "github-review" | "github-review-comment"; externalId?: string; reviewState?: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED"; }, runContext?: RunMutationContext,): Promise { { const layer = store.asyncLayer!; - const state = await getLiveTaskColumn(layer.db, id, layer.projectId); + const state = await getLiveTaskColumn(layer.db, id, layer.projectId, await resolveArchivedLanes(store)); if (state === "archived") throw new Error(`Task ${id} is archived — comments are read-only`); if (state === null) throw new Error(`Task ${id} not found`); } @@ -326,7 +326,12 @@ FNXC:WorkflowLifecycleColumns 2026-07-30-23:40: Shared by both document paths so the "is this card archived?" answer cannot differ between the write guard and the publication guard — one saying yes while the other says no is how a card ends up both read-only and un-publishable. -*/ + +FNXC:WorkflowLifecycleColumns 2026-07-31-04:10: +The helper that used to live here now lives in `project-lane-vocabulary.ts` and is imported. It grew a +THIRD caller (`getLiveTaskColumn`, whose sentinel a dozen sites compare against), and three private +copies of one fact is how the disagreement above happens at scale rather than between two functions. +The analysis below is unchanged and still governs the shape. /* FNXC:WorkflowLifecycleColumns 2026-07-31-03:35 (#2886 review — greptile P1, "project-wide lanes misclassify tasks"): THE FINDING IS RIGHT AND THE OBVIOUS FIX IS A WORSE TRADE. Measured, not argued. @@ -355,13 +360,6 @@ distinguished from "selection resolved to the default", i.e. the provenance form Sized here rather than faked, because swapping one defect for a larger one would have looked like progress and dropped a guard count. */ -async function resolveArchivedLanes(store: TaskStore): Promise | undefined> { - try { - return await resolveProjectColumnsForRoles(store, ["archived"]); - } catch { - return undefined; - } -} export async function publishArchivedTaskDocumentAdditionImpl( store: TaskStore, diff --git a/packages/core/src/task-store/task-artifacts-ops.ts b/packages/core/src/task-store/task-artifacts-ops.ts index 42c689e547..451ed892b3 100644 --- a/packages/core/src/task-store/task-artifacts-ops.ts +++ b/packages/core/src/task-store/task-artifacts-ops.ts @@ -33,6 +33,7 @@ import { existsSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { storeLog } from "../store.js"; +import { resolveArchivedLanes } from "../project-lane-vocabulary.js"; export function listWorkflowWorkItemsForTaskSyncImpl(store: TaskStore, taskId: string, opts: { kinds?: WorkflowWorkItemKind[] } = {}): WorkflowWorkItem[] { const conditions = ["taskId = ?"]; @@ -654,7 +655,7 @@ export async function addSteeringCommentImpl(store: TaskStore, id: string, text: export async function updateTaskCommentImpl(store: TaskStore, id: string, commentId: string, text: string): Promise { { const layer = store.asyncLayer!; - const state = await getLiveTaskColumn(layer.db, id, layer.projectId); + const state = await getLiveTaskColumn(layer.db, id, layer.projectId, await resolveArchivedLanes(store)); if (state === "archived") throw new Error(`Task ${id} is archived — comments are read-only`); if (state === null) throw new Error(`Task ${id} not found`); } @@ -688,7 +689,7 @@ export async function updateTaskCommentImpl(store: TaskStore, id: string, commen export async function deleteTaskCommentImpl(store: TaskStore, id: string, commentId: string): Promise { { const layer = store.asyncLayer!; - const state = await getLiveTaskColumn(layer.db, id, layer.projectId); + const state = await getLiveTaskColumn(layer.db, id, layer.projectId, await resolveArchivedLanes(store)); if (state === "archived") throw new Error(`Task ${id} is archived — comments are read-only`); if (state === null) throw new Error(`Task ${id} not found`); } @@ -782,24 +783,24 @@ export async function getArtifactImpl(store: TaskStore, id: string): Promise { const layer = store.asyncLayer!; - const updated = await updateArtifactRowAsync(layer, id, updates); + const updated = await updateArtifactRowAsync(layer, id, updates, await resolveArchivedLanes(store)); store.emit("artifact:updated", updated); return updated; } export async function getArtifactsImpl(store: TaskStore, taskId: string): Promise { const layer = store.asyncLayer!; - return getArtifactsAsync(layer.db, taskId, layer.projectId); + return getArtifactsAsync(layer.db, taskId, layer.projectId, await resolveArchivedLanes(store)); } export async function getTaskDocumentsImpl(store: TaskStore, taskId: string): Promise { const layer = store.asyncLayer!; - return listTaskDocumentsAsync(layer.db, taskId, layer.projectId); + return listTaskDocumentsAsync(layer.db, taskId, layer.projectId, await resolveArchivedLanes(store)); } export async function getTaskDocumentImpl(store: TaskStore, taskId: string, key: string): Promise { const layer = store.asyncLayer!; - return getTaskDocumentAsync(layer.db, taskId, key, layer.projectId); + return getTaskDocumentAsync(layer.db, taskId, key, layer.projectId, await resolveArchivedLanes(store)); } export async function getTaskDocumentRevisionsImpl(store: TaskStore, @@ -815,7 +816,7 @@ export async function getTaskDocumentRevisionsImpl(store: TaskStore, to preserve that ordering exactly, then apply the optional LIMIT. */ const layer = store.asyncLayer!; - const rows = await getTaskDocumentRevisionsAsync(layer.db, taskId, key, layer.projectId); + const rows = await getTaskDocumentRevisionsAsync(layer.db, taskId, key, layer.projectId, await resolveArchivedLanes(store)); const sorted = [...rows].sort((a, b) => b.revision - a.revision); const mapped = sorted.map((row) => store.rowToTaskDocumentRevision(row)); return options?.limit !== undefined ? mapped.slice(0, Math.max(0, options.limit)) : mapped; @@ -831,7 +832,7 @@ export async function deleteTaskDocumentImpl(store: TaskStore, taskId: string, k only live tasks so a present task implies deletedAt == null. */ const layer = store.asyncLayer!; - await deleteTaskDocumentAsync(layer, taskId, key); + await deleteTaskDocumentAsync(layer, taskId, key, await resolveArchivedLanes(store)); const task = await store.getTask(taskId); if (task) { store.emit("task:updated", task); diff --git a/packages/core/src/task-store/task-id-integrity.ts b/packages/core/src/task-store/task-id-integrity.ts index 4552c36c68..7ea2fe7f43 100644 --- a/packages/core/src/task-store/task-id-integrity.ts +++ b/packages/core/src/task-store/task-id-integrity.ts @@ -36,6 +36,7 @@ import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { DependencyCycleError, TaskDeletedError, TombstonedTaskResurrectionError, coreLog, detectDependencyCycle, storeLog } from "../store.js"; +import { resolveArchivedLanes } from "../project-lane-vocabulary.js"; export function trackDeferredTaskCreatedWorkImpl(store: TaskStore, work: () => Promise): Promise { if (store.closing) return Promise.resolve(); @@ -451,7 +452,7 @@ getLiveTaskColumn, plus cold archive.archived_tasks presence. export async function isTaskArchivedAsyncImpl(store: TaskStore, id: string): Promise { const layer = store.asyncLayer!; - const live = await getLiveTaskColumn(layer.db, id, layer.projectId); + const live = await getLiveTaskColumn(layer.db, id, layer.projectId, await resolveArchivedLanes(store)); // getLiveTaskColumn returns "archived" for archived OR soft-deleted rows. if (live === "archived") return true; if (live !== null) return false; diff --git a/packages/core/src/task-store/task-mutation-ops.ts b/packages/core/src/task-store/task-mutation-ops.ts index d0e9ce71a6..48442f93a7 100644 --- a/packages/core/src/task-store/task-mutation-ops.ts +++ b/packages/core/src/task-store/task-mutation-ops.ts @@ -42,6 +42,7 @@ import {appendConfigurationRevision, createConfigurationRevision, getConfigurati import {readProjectConfig, writeProjectConfig} from "./async-settings.js"; import {publishSettingsUpdated} from "./settings-ops.js"; import type {ConfigChangedBy, ConfigurationRevision} from "../types.js"; +import { resolveArchivedLanes } from "../project-lane-vocabulary.js"; export function getTaskSelectClauseWithActivityLogLimitImpl(store: TaskStore, limit: number): string { const columns = [ @@ -955,7 +956,7 @@ export async function registerArtifactImpl(store: TaskStore, input: ArtifactCrea FNXC:SqliteDualPathCleanup 2026-07-26-14:07: Artifact row insert is PostgreSQL-only via insertArtifactRowAsync. */ - return insertArtifactRowAsync(store.asyncLayer!, input, stored); + return insertArtifactRowAsync(store.asyncLayer!, input, stored, await resolveArchivedLanes(store)); } catch (error) { if (stored.absolutePath) { await unlink(stored.absolutePath).catch(() => undefined); diff --git a/packages/core/src/task-store/workflow-definitions.ts b/packages/core/src/task-store/workflow-definitions.ts index b3f26e15e2..ed3fd615b0 100644 --- a/packages/core/src/task-store/workflow-definitions.ts +++ b/packages/core/src/task-store/workflow-definitions.ts @@ -44,6 +44,7 @@ import { resolveSwitchReconciliation } from "../workflow-reconciliation.js"; import { WORKFLOW_COMPILED_STEP_TEMPLATE_PREFIX } from "../store.js"; import { resolveWorkflowIrForTask } from "../workflow-ir-resolver.js"; import { resolveProjectColumnsForRoles, REVIEW_ROLES } from "../project-lane-vocabulary.js"; +import type { InReviewDurationLanes } from "./async-audit.js"; export async function getAgentLogsByTimeRangeImpl(store: TaskStore, taskId: string, @@ -1008,7 +1009,22 @@ failed resolve leaves the query on its documented legacy lanes rather than faili */ export async function getInReviewDurationEventsImpl(store: TaskStore, options: { since: string; until: string }): Promise { const layer = store.asyncLayer!; - let lanes: { reviewColumns?: string[]; completeColumns?: string[] } | undefined; + /* + FNXC:WorkflowLifecycleColumns 2026-07-31-06:20: + Named type, not an inferred literal, and the reason is a tool contract rather than style. + + `scripts/lib/unwired-lane-parameter.mjs` decides a lane parameter is WIRED when some other file + mentions both the parameter name and its declaring symbol. For an interface passed as an inferred + object literal there is no mention of the type anywhere, so this call site — which does supply + both lanes — was reported as unwired, and #2875 landed two false entries into a guard written to + catch the opposite mistake. + + Annotating the local is the smallest honest fix. I tried three variations of the heuristic first; + each traded the false positive for false NEGATIVES (the widest hid twelve genuine entries), which + is the usual sign that a co-occurrence check has reached its limit. Naming the type costs one + word, makes the wiring visible to both the reader and the tool, and leaves the guard's rule alone. + */ + let lanes: InReviewDurationLanes | undefined; try { const [review, complete] = await Promise.all([ resolveProjectColumnsForRoles(store, REVIEW_ROLES), diff --git a/scripts/lib/lifecycle-column-census-baseline.json b/scripts/lib/lifecycle-column-census-baseline.json index 5753061c5b..b1bf22769d 100644 --- a/scripts/lib/lifecycle-column-census-baseline.json +++ b/scripts/lib/lifecycle-column-census-baseline.json @@ -8,9 +8,9 @@ "packages/engine/src/notification/notification-service.ts": 5, "packages/engine/src/replan-target.ts": 4, "packages/engine/src/restart-recovery-coordinator.ts": 4, - "packages/core/src/async-mission-store-queries.ts": 3, "packages/core/src/task-store/task-artifacts-ops.ts": 3, "packages/core/src/agent-store.ts": 2, + "packages/core/src/async-mission-store-queries.ts": 2, "packages/core/src/task-store/audit-ops.ts": 2, "packages/core/src/task-store/moves.ts": 2, "packages/core/src/task-store/project-store-ops.ts": 2,