diff --git a/packages/core/src/__tests__/log-entry-archived-lane-gate.test.ts b/packages/core/src/__tests__/log-entry-archived-lane-gate.test.ts index 8d3166a85c..886537b179 100644 --- a/packages/core/src/__tests__/log-entry-archived-lane-gate.test.ts +++ b/packages/core/src/__tests__/log-entry-archived-lane-gate.test.ts @@ -8,7 +8,7 @@ deliberately does NOT assert the renamed-lane case. The gap is narrow rather than absent because `deletedAt` covers the soft-delete half and that is the common path — which is also why it stayed invisible. -I CONVERTED IT, AND BACKED THE CONVERSION OUT. `archived-column-gate-parity.test.ts` caught it, and +I CONVERTED IT, BACKED IT OUT, AND HAVE NOW CONVERTED IT PROPERLY. `archived-column-gate-parity.test.ts` caught it, and its reasoning is correct and not obvious: this gate has THREE encodings — TypeScript comparisons, Drizzle `eq`/`ne` predicates, and raw SQL templates — and converting only the TypeScript arm makes them DIVERGE. TS would call the row archived while the SQL side still returns it as live: a log write @@ -67,11 +67,20 @@ function row(column: string) { describe("the log-entry archive gate", () => { /* - DELIBERATELY ABSENT: "refuses a log write to a card in a RENAMED archive lane". That is the - behaviour this gate SHOULD have and does not, and it cannot be fixed in the TypeScript arm alone — - see the header. Written down rather than left as a silent hole, so the next reader knows the - omission is a decision and not an oversight. + FNXC:WorkflowResolvedColumns 2026-07-31-23:59: the renamed case is NO LONGER ABSENT — it is the + first case below. The omission recorded here said the gate "cannot be fixed in the TypeScript arm + alone". That was the right call on the evidence then and is now wrong: the conversion is ADDITIVE, + keeping `pgRow.column === "archived"` verbatim as the fallback, so no encoding's literal count moves + and the parity gate is satisfied rather than bypassed. */ + it("refuses a log write to a card in a RENAMED archive lane", async () => { + readTaskRowMock.mockResolvedValue(row("filed")); + + await expect(logEntryImpl(storeWith(RENAMED_IR), "KB-1", "did a thing")) + .rejects.toThrow(/archived — logging is read-only/); + }); + + /* CONTROL: the resolved set is legacy-seeded, so the built-in id must still refuse. */ it("refuses a log write to the legacy `archived` column", async () => { readTaskRowMock.mockResolvedValue(row("archived")); diff --git a/packages/core/src/task-store/audit-ops.ts b/packages/core/src/task-store/audit-ops.ts index 51918b77ef..8db6e14467 100644 --- a/packages/core/src/task-store/audit-ops.ts +++ b/packages/core/src/task-store/audit-ops.ts @@ -228,7 +228,29 @@ export async function logEntryImpl(store: TaskStore, id: string, action: string, Behaviour here is otherwise now covered by `log-entry-archived-lane-gate.test.ts`, which had no test at all before and which records the renamed case as a deliberate, explained omission. */ - if (pgRow.column === "archived" || pgRow.deletedAt != null) { + /* + FNXC:WorkflowResolvedColumns 2026-07-31-23:59 (converted — the parity gate's objection is met, + not worked around): + A LANE question: "is this row in the board's archive lane, so logging is read-only?" Against the + literal, a card the operator filed away on a renamed board kept ACCEPTING log writes — new + activity accruing on closed work. `deletedAt` covers the soft-delete half, which is why the gap + is narrow and why it stayed invisible: the common path is soft-delete. + + I converted this once before and REVERTED it, because the parity gate failed. The gate was + right, and the reason was subtler than "one encoding moved": my version hoisted the comparison + onto a local (`pgRowColumn === "archived"`), and that gate's TS scan keys on the PROPERTY being + named `column`. Dropping the `.column` access dropped the TS count while SQL and raw held, which + it reads as divergence. + + So the fallback keeps `pgRow.column === "archived"` VERBATIM. The resolved path is added in + front of it, no encoding's count moves, and an unwired or degraded caller behaves exactly as + before — the same additive shape as the six Drizzle LANE sites. + */ + const archivedLanes = await resolveArchivedLanes(store); + const rowIsArchivedLane = archivedLanes + ? archivedLanes.has(String(pgRow.column ?? "")) + : pgRow.column === "archived"; + if (rowIsArchivedLane || pgRow.deletedAt != null) { throw new Error(`Task ${id} is archived — logging is read-only`); } // PG jsonb columns arrive already-parsed; convert to the TaskLogEntry[] shape.