batch-u7-lane-fixes: three core/engine renamed-board fixes folded (was #2925, #2930, #2936) (#2925)

**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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-30 19:07:09 -07:00
committed by GitHub
parent 97c987c40b
commit b4ed12e9c8
17 changed files with 269 additions and 48 deletions

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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();

View File

@@ -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" });

View File

@@ -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<TerminalTaskEvidence> {
export async function getTerminalTaskEvidence(
handle: QueryHandle,
taskId: string,
terminalColumns?: { complete?: ReadonlySet<string>; archived?: ReadonlySet<string> },
): Promise<TerminalTaskEvidence> {
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 };

View File

@@ -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<MissionStoreEvents> {
);
}
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`);
}

View File

@@ -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";

View File

@@ -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,

View File

@@ -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<ReadonlySet<string> | undefined> {
try {
return await resolveProjectColumnsForRoles(store, ["archived"]);
} catch {
return undefined;
}
}

View File

@@ -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<string>,
): Promise<string | null> {
/*
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<TaskDocument | null> {
const column = await getLiveTaskColumn(db, taskId, projectId);
archivedColumns?: ReadonlySet<string>,): Promise<TaskDocument | null> {
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<TaskDocument[]> {
const column = await getLiveTaskColumn(db, taskId, projectId);
archivedColumns?: ReadonlySet<string>,): Promise<TaskDocument[]> {
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<TaskDocumentRevisionRow[]> {
const column = await getLiveTaskColumn(db, taskId, projectId);
archivedColumns?: ReadonlySet<string>,): Promise<TaskDocumentRevisionRow[]> {
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<void> {
archivedColumns?: ReadonlySet<string>,): Promise<void> {
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<Artifact> {
archivedColumns?: ReadonlySet<string>,): Promise<Artifact> {
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<Artifact> {
archivedColumns?: ReadonlySet<string>,): Promise<Artifact> {
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<Artifact[]> {
const column = await getLiveTaskColumn(db, taskId, projectId);
archivedColumns?: ReadonlySet<string>,): Promise<Artifact[]> {
const column = await getLiveTaskColumn(db, taskId, projectId, archivedColumns);
if (column === null || column === "archived") return [];
const rows = await db

View File

@@ -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<void> {
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

View File

@@ -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<Task> {
{
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<ReadonlySet<string> | undefined> {
try {
return await resolveProjectColumnsForRoles(store, ["archived"]);
} catch {
return undefined;
}
}
export async function publishArchivedTaskDocumentAdditionImpl(
store: TaskStore,

View File

@@ -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<Task> {
{
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<Task> {
{
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<Art
*/
export async function updateArtifactImpl(store: TaskStore, id: string, updates: { title?: string; description?: string; content?: string }): Promise<Artifact> {
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<Artifact[]> {
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<TaskDocument[]> {
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<TaskDocument | null> {
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);

View File

@@ -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<void>): Promise<void> {
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<boolean> {
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;

View File

@@ -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);

View File

@@ -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<ActivityLogEntry[]> {
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),

View File

@@ -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,