fleet: async-comments-attachments.ts NOT converted — the archived gate lives in three encodings (52 sites), pinned by a guard that fails on each (#2724)
Claimed the **async-comments-attachments** cluster (9) and did not convert it. This PR is the evidence for why, as a guard rather than a note — **no production change, census unmoved.** ## What the cluster actually is Every other file in the backlog converts on its own: resolve the task's lifecycle columns, compare against the role. `archived` doesn't, and not by a matter of degree. **Measured in `packages/core` — one rule, three encodings, 52 sites:** | encoding | sites | files | what it decides | |---|---:|---:|---| | TypeScript `=== "archived"` | **37** | 23 | what code does with a row it already has | | Drizzle `ne(tasks.column, 'archived')` | **7** | 6 | which rows a query returns | | raw `` sql`…column != 'archived'` `` | **8** | 5 | same — and invisible to both scans above | Convert only the TypeScript half and a board whose archived lane is renamed **splits**: `getLiveTaskColumn` correctly reports the task archived (it resolved the role) while `readLiveTaskRows` still hands it back as live. A document write is rejected by its gate while its parent is listed as live — a state neither gate alone can produce today. And nothing would catch it. **Every builtin workflow spells that column `archived`**, so all three encodings agree by accident on every board we ship. ## Why the SQL sides aren't just converted too `ne(tasks.column, ...)` needs the resolved id as a **query-build value**, so the IR must be resolved *before* the query — including inside the `for update` document/artifact transactions, which today receive a `db`/`tx` handle and **no store and no workflow reader**. One raw site is a hand-written `SELECT` string, so its comparison isn't even a Drizzle expression that could take a bound value without rewriting the query. The two real options are: thread a resolver into the persistence layer, or **declare `archived` a non-renameable system column** and mark all 52 sites deliberate. Both are decisions with blast radius. Neither is a fleet conversion, so I didn't pick one. ## I was wrong about the shape, and that's the strongest part I wrote the third case as an assertion that **no** raw `sql` template compares a column to `'archived'` — I assumed two encodings. **It failed on the first run with five files.** Nothing in the repo was counting them: they aren't comparisons (invisible to the column census) and aren't `eq`/`ne` calls (invisible to the Drizzle scan). A partial conversion doesn't have to miss one encoding — it can miss two. That case is now an inventory, with a note on why asserting absence was the wrong invariant: an absence assertion has to be deleted by whoever adds the next raw template, and deleting a red guard is how a class of sites stops being tracked. ## Injection proof — all three run | injected | result | |---|---| | convert one TS comparison in `async-comments-attachments.ts` | fails: **"TypeScript encoding changed"** | | remove one Drizzle predicate in `async-lifecycle.ts` | fails: **"Drizzle encoding changed"** | | remove one raw template in `reads.ts` | fails: **"Raw-sql encoding changed"** | Each failure carries the split-brain explanation and the two real options, so the next worker hits the reason rather than a bare count mismatch. ## Two scan bugs I fixed in my own guard - **It audited documentation.** The raw-template scan reported three sites in `async-archive-lineage.ts` that were prose in a JSDoc block. Now comment-stripped through the census's shared `stripComments` rather than a second implementation. - **It missed half the cluster.** Requiring a receiver (`x.column === "archived"`) misses the list paths that hold the value in a bare local (`if (column === null || column === "archived") return []`) — **four of the eight sites in the target file**, measured. The matcher now accepts a bare identifier named `column`. Counts here are per file, not per line: line numbers churn on unrelated edits, and a ratchet that cries wolf gets deleted. ## Verification `pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · new guard **2/2** · `pnpm lint` clean · core `tsc` clean · census `--strict` exits 0 with the **baseline untouched** — this PR converts nothing and claims nothing. 🤖 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:
287
packages/core/src/__tests__/archived-column-gate-parity.test.ts
Normal file
287
packages/core/src/__tests__/archived-column-gate-parity.test.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-31-00:40 (fleet phase — why `async-comments-attachments.ts` was NOT converted):
|
||||
THE ARCHIVED GATE IS ENFORCED IN THREE ENCODINGS, AND CONVERTING ONE OF THEM IS A SPLIT BRAIN.
|
||||
|
||||
Every other file in the column-literal backlog can be converted on its own: resolve the task's
|
||||
lifecycle columns, compare against the role. `archived` is different, and the difference is not a
|
||||
matter of degree.
|
||||
|
||||
MEASURED on this tree — THREE encodings, 50 sites, all in packages/core:
|
||||
- 35 TypeScript comparisons against the literal `"archived"` across 23 files.
|
||||
|
||||
(Was 37 when this guard landed. Two dropped as conversions merged — `agent-store.ts`'s claim guard in
|
||||
#2746 and one in `update-task-deps.ts` — and the guard FAILED until this inventory was updated to match,
|
||||
which is the ratchet working: it notices the TypeScript half moving in either direction, not only up.)
|
||||
- 7 Drizzle predicates pushing the same rule into SQL as `ne(tasks.column, 'archived')`, in 6 files.
|
||||
- 8 RAW `sql` template comparisons (`sql`${tasks.column} != 'archived'`` and one hand-written
|
||||
`SELECT ... "column" = 'archived'`), in 5 files.
|
||||
|
||||
I expected two halves and asserted the third was empty. It was not: the raw-template scan came back
|
||||
with five files on the first run. That is the strongest argument in this file — a partial conversion
|
||||
does not have to miss one encoding, it can miss two, and nothing in the codebase is counting them.
|
||||
|
||||
The SQL halves decide which rows a query RETURNS. The TypeScript half decides what the code does with
|
||||
a row it already has. All three are encodings of one sentence: "an archived task is not live."
|
||||
|
||||
Convert only the TypeScript half and a board whose archived lane is renamed splits:
|
||||
`getLiveTaskColumn` correctly reports the task archived (it resolved the role), while
|
||||
`readLiveTaskRows` still hands that task back as live (its SQL still compares the string). A document
|
||||
write is then rejected by one gate and its parent listed by the other — a state neither gate alone can
|
||||
produce today, and one that no test would catch, because every builtin workflow spells the column
|
||||
`archived` so the two halves agree by accident on every board we ship.
|
||||
|
||||
WHY THE SQL HALVES CANNOT JUST BE CONVERTED TOO. `ne(tasks.column, ...)` needs the resolved id as a
|
||||
VALUE at query-build time, so the workflow IR must be resolved BEFORE the query — including inside
|
||||
`for update` transactions in the document/artifact paths, which currently take no store and no
|
||||
workflow reader (they receive a `db`/`tx` handle and a task id). Threading a resolver into the
|
||||
persistence layer, or declaring `archived` a non-renameable system column and marking all 52 sites
|
||||
deliberate, are both real decisions with real blast radius. Neither is a fleet conversion. The raw
|
||||
templates are worse again: one of them is a hand-written `SELECT` string, so its comparison is not even
|
||||
a Drizzle expression that could take a bound value without rewriting the query.
|
||||
|
||||
SO THIS FILE IS THE GUARD, NOT THE FIX. It fails when any of the three encodings stops matching its
|
||||
audited inventory below — which is exactly what a well-intentioned partial conversion does.
|
||||
|
||||
REVERT CHECK, measured. Converting the `archived` comparisons in `async-comments-attachments.ts` (the
|
||||
shape a conversion PR would produce) drops its audited count from 8 and fails the lockstep case with the
|
||||
split-brain message. Removing one Drizzle predicate and removing one raw template each fail it from
|
||||
their own side. All three were run.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Files that compare a task column against the literal `"archived"` in TypeScript, with the number
|
||||
* of comparisons in each. Counts, not line numbers: line numbers churn on every unrelated edit and a
|
||||
* ratchet that cries wolf gets deleted.
|
||||
*/
|
||||
const AUDITED_TS_SITES: Readonly<Record<string, number>> = {
|
||||
"packages/core/src/agent-store.ts": 1,
|
||||
"packages/core/src/assigned-task-ranking.ts": 1,
|
||||
"packages/core/src/async-mission-store-queries.ts": 2,
|
||||
"packages/core/src/async-mission-store.ts": 2,
|
||||
"packages/core/src/blocker-fanout.ts": 1,
|
||||
"packages/core/src/duplicate-intake.ts": 1,
|
||||
"packages/core/src/eval-signal-collector.ts": 1,
|
||||
"packages/core/src/live-agent-count.ts": 1,
|
||||
"packages/core/src/mission-store.ts": 1,
|
||||
"packages/core/src/near-duplicate-canonical.ts": 1,
|
||||
"packages/core/src/store.ts": 2,
|
||||
"packages/core/src/task-merge.ts": 2,
|
||||
"packages/core/src/task-store/archive-lifecycle-2.ts": 2,
|
||||
"packages/core/src/task-store/async-comments-attachments.ts": 8,
|
||||
"packages/core/src/task-store/audit-ops.ts": 1,
|
||||
"packages/core/src/task-store/branch-and-pr-entities.ts": 1,
|
||||
"packages/core/src/task-store/branch-group-ops.ts": 1,
|
||||
"packages/core/src/task-store/lifecycle-ops.ts": 1,
|
||||
"packages/core/src/task-store/moves.ts": 1,
|
||||
"packages/core/src/task-store/symbol-locks.ts": 1,
|
||||
"packages/core/src/task-store/task-id-integrity.ts": 1,
|
||||
"packages/core/src/task-store/task-store-helpers.ts": 1,
|
||||
"packages/core/src/task-store/update-task-deps.ts": 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* Raw `sql` template comparisons — THE THIRD ENCODING, and the one this file was written expecting not
|
||||
* to exist. Invisible to the column census (not a comparison) and invisible to the Drizzle scan below
|
||||
* (not an `eq`/`ne` call), so before this inventory nothing in the repo counted them at all.
|
||||
*/
|
||||
const AUDITED_RAW_SQL_SITES: Readonly<Record<string, number>> = {
|
||||
"packages/core/src/async-mission-store-queries.ts": 1,
|
||||
"packages/core/src/async-mission-store.ts": 2,
|
||||
"packages/core/src/task-store/async-archive-lineage.ts": 3,
|
||||
"packages/core/src/task-store/async-maintenance.ts": 1,
|
||||
"packages/core/src/task-store/reads.ts": 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* Drizzle predicates pushing the same rule into SQL. THE HALF A CONVERSION PR FORGETS — it is not a
|
||||
* column comparison in any grep the column census runs, so the census reports these files as clean.
|
||||
*/
|
||||
const AUDITED_SQL_SITES: Readonly<Record<string, number>> = {
|
||||
"packages/core/src/store.ts": 1,
|
||||
"packages/core/src/task-store/async-lifecycle.ts": 1,
|
||||
"packages/core/src/task-store/async-search.ts": 1,
|
||||
"packages/core/src/task-store/async-self-healing.ts": 1,
|
||||
"packages/core/src/task-store/branch-and-pr-entities.ts": 2,
|
||||
"packages/core/src/task-store/task-mutation-ops.ts": 1,
|
||||
};
|
||||
|
||||
const SPLIT_BRAIN_EXPLANATION = [
|
||||
"",
|
||||
"THE ARCHIVED GATE HAS THREE ENCODINGS AND THEY MUST MOVE TOGETHER.",
|
||||
"",
|
||||
" 1. TypeScript comparisons (AUDITED_TS_SITES) — what code does with a row it has",
|
||||
" 2. Drizzle eq/ne predicates (AUDITED_SQL_SITES) — which rows a query returns",
|
||||
" 3. Raw sql templates (AUDITED_RAW_SQL_SITES) — same, invisible to both scans above",
|
||||
"",
|
||||
"If you converted the TypeScript comparisons to a resolved `archived` role, encodings 2 and 3",
|
||||
"still compare the raw string. On a board whose archived lane is renamed, one says a task is",
|
||||
"archived while the others return it as live — a document write rejected by its gate while its",
|
||||
"parent is listed as live.",
|
||||
"",
|
||||
"Every builtin workflow names that column `archived`, so all three agree by accident on every",
|
||||
"board we ship and no existing test can see the divergence.",
|
||||
"",
|
||||
"If you are converting on purpose: convert ALL THREE (the SQL sides need the resolved id as a",
|
||||
"query-build value, including inside the `for update` document/artifact transactions that",
|
||||
"currently receive no store, and one raw site is a hand-written SELECT string), or declare",
|
||||
"`archived` a non-renameable system column and mark the sites deliberate. Then update the",
|
||||
"inventories in this file in the SAME commit.",
|
||||
"",
|
||||
"If you did not mean to touch the archived gate, you added or removed a raw `archived`",
|
||||
"comparison — route it through the resolved role or add it to the inventory with a reason.",
|
||||
].join("\n");
|
||||
|
||||
interface Site {
|
||||
readonly file: string;
|
||||
readonly line: number;
|
||||
}
|
||||
|
||||
async function repoFiles(): Promise<{ root: string; files: string[] }> {
|
||||
const { execFileSync } = await import("node:child_process");
|
||||
const { resolve } = await import("node:path");
|
||||
const root = resolve(__dirname, "../../../..");
|
||||
const files = execFileSync("git", ["ls-files", "--", "packages/core/src"], { cwd: root, encoding: "utf-8" })
|
||||
.split("\n")
|
||||
.filter((f) => f && f.endsWith(".ts") && !f.includes("__tests__") && !f.includes("__test-utils__"));
|
||||
return { root, files };
|
||||
}
|
||||
|
||||
function tally(sites: readonly Site[]): Record<string, number> {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const site of sites) counts[site.file] = (counts[site.file] ?? 0) + 1;
|
||||
return counts;
|
||||
}
|
||||
|
||||
describe("the archived-state gate is enforced in TypeScript AND in SQL", () => {
|
||||
it("all three encodings of the archived gate stay in lockstep with the audited inventory", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const { resolve } = await import("node:path");
|
||||
const { createRequire } = await import("node:module");
|
||||
const ts = createRequire(import.meta.url)("typescript") as typeof import("typescript");
|
||||
const { root, files } = await repoFiles();
|
||||
|
||||
// Self-check: a resolution bug that yields no files would make every assertion below vacuous.
|
||||
expect(files.length).toBeGreaterThan(100);
|
||||
|
||||
const { stripComments } = await import("../../../../scripts/lib/lifecycle-column-census.mjs") as {
|
||||
stripComments: (source: string) => string;
|
||||
};
|
||||
|
||||
const tsSites: Site[] = [];
|
||||
const sqlSites: Site[] = [];
|
||||
const rawSqlCounts: Record<string, number> = {};
|
||||
|
||||
/*
|
||||
The raw-template encoding, matched on comment-STRIPPED source. Reusing the census's `stripComments`
|
||||
rather than a second implementation: an earlier version of this scan counted three "sites" in
|
||||
async-archive-lineage.ts that were prose in a JSDoc block, which is how a guard ends up auditing
|
||||
documentation.
|
||||
*/
|
||||
const RAW_SQL_ARCHIVED = /sql`[^`]*?(?:\.column|"column"|\bcolumn)[^`]*?'archived'/gs;
|
||||
|
||||
for (const file of files) {
|
||||
const source = readFileSync(resolve(root, file), "utf-8");
|
||||
if (!source.includes("archived")) continue;
|
||||
|
||||
const rawMatches = stripComments(source).match(RAW_SQL_ARCHIVED);
|
||||
if (rawMatches) rawSqlCounts[file] = rawMatches.length;
|
||||
|
||||
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
|
||||
|
||||
const lineOf = (node: import("typescript").Node): number =>
|
||||
sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
||||
|
||||
const visit = (node: import("typescript").Node): void => {
|
||||
/*
|
||||
TS half: `<something>.column === "archived"` (or `!==`). Keyed on the PROPERTY being named
|
||||
`column` rather than on the receiver, because the receiver is variously `task`, `row`,
|
||||
`currentTask`, `dep` and `t` — matching receiver names is how the earlier counts of this
|
||||
backlog disagreed with each other.
|
||||
*/
|
||||
if (ts.isBinaryExpression(node)) {
|
||||
const isEquality = node.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken
|
||||
|| node.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken;
|
||||
if (isEquality) {
|
||||
for (const [a, b] of [[node.left, node.right], [node.right, node.left]] as const) {
|
||||
if (!ts.isStringLiteral(b) || b.text !== "archived") continue;
|
||||
/*
|
||||
A property named `column` OR a bare local named `column`. The bare form matters: the
|
||||
document/artifact list paths hold the value in a local (`if (column === null || column ===
|
||||
"archived") return []`), and a scan requiring a receiver misses four of the eight sites in
|
||||
async-comments-attachments.ts — measured, that is exactly what the first version did.
|
||||
*/
|
||||
const inner = ts.isNonNullExpression(a) ? a.expression : a;
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-31-12:50 (#2724 review — greptile P2):
|
||||
ELEMENT ACCESS too. The first version accepted `x.column` and a bare `column`, so
|
||||
`row["column"] === "archived"` walked straight past the inventory — a guard defeated by
|
||||
changing syntax, which is precisely the failure I fixed in the maxWorktrees audit by moving
|
||||
from regex to AST and then re-introduced here in a narrower form.
|
||||
|
||||
Optional chaining needs no special case: `a?.b` is still a PropertyAccessExpression in the
|
||||
TypeScript AST, so it was already covered.
|
||||
*/
|
||||
const named = ts.isPropertyAccessExpression(inner)
|
||||
? inner.name.text
|
||||
: ts.isElementAccessExpression(inner)
|
||||
&& inner.argumentExpression
|
||||
&& ts.isStringLiteral(inner.argumentExpression)
|
||||
? inner.argumentExpression.text
|
||||
: ts.isIdentifier(inner) ? inner.text : undefined;
|
||||
if (named === "column") tsSites.push({ file, line: lineOf(node) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
SQL half: `eq(<...>.tasks.column, "archived")` / `ne(...)`. Drizzle builds the predicate as a
|
||||
call, so this is a CallExpression whose first argument is the tasks.column Column object and
|
||||
whose second is the literal.
|
||||
*/
|
||||
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
|
||||
const fn = node.expression.text;
|
||||
if ((fn === "eq" || fn === "ne") && node.arguments.length === 2) {
|
||||
const [columnArg, valueArg] = node.arguments;
|
||||
const isTasksColumn = columnArg
|
||||
&& ts.isPropertyAccessExpression(columnArg)
|
||||
&& columnArg.name.text === "column"
|
||||
&& ts.isPropertyAccessExpression(columnArg.expression)
|
||||
&& columnArg.expression.name.text === "tasks";
|
||||
if (isTasksColumn && valueArg && ts.isStringLiteral(valueArg) && valueArg.text === "archived") {
|
||||
sqlSites.push({ file, line: lineOf(node) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sf);
|
||||
}
|
||||
|
||||
// Neither half may be empty: an empty side would let the lockstep assertion pass trivially.
|
||||
expect(sqlSites.length).toBeGreaterThan(0);
|
||||
expect(tsSites.length).toBeGreaterThan(0);
|
||||
|
||||
expect(Object.keys(rawSqlCounts).length).toBeGreaterThan(0);
|
||||
|
||||
expect(tally(tsSites), `TypeScript encoding changed.${SPLIT_BRAIN_EXPLANATION}`).toEqual(AUDITED_TS_SITES);
|
||||
expect(tally(sqlSites), `Drizzle encoding changed.${SPLIT_BRAIN_EXPLANATION}`).toEqual(AUDITED_SQL_SITES);
|
||||
expect(rawSqlCounts, `Raw-sql encoding changed.${SPLIT_BRAIN_EXPLANATION}`).toEqual(AUDITED_RAW_SQL_SITES);
|
||||
});
|
||||
|
||||
it("the raw-sql encoding is audited by FILE INVENTORY, not assumed absent", () => {
|
||||
/*
|
||||
This case exists as a headstone. It was originally written the other way round — asserting that NO
|
||||
raw `sql` template compares a column to 'archived', on the assumption that the gate had only two
|
||||
encodings. It failed on the first run with five files.
|
||||
|
||||
Kept as an assertion that the inventory is non-empty, because the useful invariant turned out to be
|
||||
the opposite of the one I set out to write: the third encoding EXISTS, is unavoidable for now, and
|
||||
must be counted. An assertion of absence here would have to be deleted by whoever added the next
|
||||
raw template, and deleting a red guard is how a class of sites stops being tracked.
|
||||
*/
|
||||
expect(Object.keys(AUDITED_RAW_SQL_SITES).length).toBeGreaterThan(0);
|
||||
expect(Object.values(AUDITED_RAW_SQL_SITES).reduce((a, b) => a + b, 0)).toBe(8);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user