fix(core): the log-entry archive gate, converted — the parity objection is met, not bypassed (#3165)

I converted this in #3110, the parity gate failed, and I reverted it.
**The gate was right** — and the reason was subtler than "one encoding
moved". Knowing it is what makes this conversion possible.

## Why the first attempt failed

My version hoisted the comparison onto a local:

```ts
const pgRowColumn = String(pgRow.column ?? "");
const rowIsArchivedLane = archivedLanes ? archivedLanes.has(pgRowColumn) : pgRowColumn === "archived";
```

That gate's TS scan keys on the **property** being named `column` —
deliberately, because the receiver is variously `task`, `row`, `dep`,
`t`. Losing the `.column` access dropped the TS count while SQL and raw
held steady, which it reads as divergence.

**Behaviourally identical, structurally invisible.** Same failure mode I
hit from the other direction in #3163, where I collapsed a Drizzle
fallback into a string array.

## The fix

Keep `pgRow.column === "archived"` **verbatim** as the fallback; add the
resolved path in front of it. No encoding's count moves, an unwired or
degraded caller behaves exactly as before, and the gate is **satisfied
rather than worked around** — the same additive shape as the six Drizzle
LANE sites (#3160, #3162, #3163).

## What it fixes

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.

## The recorded omission is retired properly

`log-entry-archived-lane-gate.test.ts` carried the renamed case as a
**deliberate omission** with its reason. It is now the first case in the
file, and the note explains why the earlier judgement changed rather
than quietly disappearing — a deferral that vanishes without explanation
is how the next reader loses the thread.

## Measured

- **3/3** in that file (renamed case added); parity test **2/2**,
inventories unmoved.
- **MUTATION**: dropping the resolved branch fails the renamed case and
leaves the legacy **control** and the live-lane **negative** green.
- log-entry / archived / audit suites — **3 files / 9 tests pass**.
- `tsc --noEmit -p packages/core` clean; census `--strict`,
`check-sql-column-literals` clean.
- `check-fnxc-future-dates` is red on `main` from `task-update.ts`
(another lane's stamps), not from these files.

## Census

**Unchanged** — the literal remains the fallback arm, by design.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-31 10:27:31 -07:00
committed by GitHub
parent 762d232ad6
commit 25fa5e7c44
2 changed files with 37 additions and 6 deletions

View File

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

View File

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