From de320094125edbadc89b960470ffe66797cdef70 Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Tue, 6 Oct 2026 18:44:22 -0700 Subject: [PATCH 1/4] feat(T13305): a refused identity refill is recorded; later opens are fast and quiet team-lead (C2 prep): a linked store whose share state reads 'unknown' (or 'shared') refused the full refill on EVERY open: the stale marker kept 'recipe' pending, so C1's fast path never applied, the whole pass ran and an error was logged each time. - resetStaleIdentity records the refusal (tasks_row_identity_meta, local-only: state, recipe, share-verdict digest, reasons, at, warnedAt) and logs a warning at most once a day (REFUSAL_WARN_INTERVAL_MS). - rowIdentityFillPending: a refusal recorded for the current recipe with the share verdict unchanged settles the stale marker (no 'recipe' pending), so later opens take the fast path. A recipe change or any change of the share signals re-evaluates. - cleo doctor row-identity --refill (dry run included) clears the refusal (refusalCleared), so the next open decides afresh; cleo doctor reports a standing refusal as a row_identity finding. Tests: second open fast (nothing pending, refill none), a new NULL-uid row is filled without re-warning; unlinking re-evaluates and refills; a refusal of another recipe is not settled; the doctor dry run clears and re-evaluates. Co-Authored-By: Claude Opus 5.5 --- packages/contracts/src/row-identity.ts | 2 + .../core/src/doctor/row-identity-refill.ts | 16 ++- packages/core/src/doctor/row-identity.ts | 10 +- .../row-identity-full-refill.test.ts | 73 +++++++++- packages/core/src/store/row-identity.ts | 136 ++++++++++++++++-- 5 files changed, 224 insertions(+), 13 deletions(-) diff --git a/packages/contracts/src/row-identity.ts b/packages/contracts/src/row-identity.ts index 1d819a88ce..71f559ff6a 100644 --- a/packages/contracts/src/row-identity.ts +++ b/packages/contracts/src/row-identity.ts @@ -227,4 +227,6 @@ export interface RowIdentityRefillReport { readonly snapshot: string | null; /** `--apply`: how to undo the refill. */ readonly undo: string | null; + /** Whether this run cleared a recorded refusal, so the next open re-evaluates (T13305). */ + readonly refusalCleared: boolean; } diff --git a/packages/core/src/doctor/row-identity-refill.ts b/packages/core/src/doctor/row-identity-refill.ts index 28ef4b891d..be91e67820 100644 --- a/packages/core/src/doctor/row-identity-refill.ts +++ b/packages/core/src/doctor/row-identity-refill.ts @@ -36,9 +36,11 @@ import { } from '../store/dual-scope-db.js'; import { openCleoDbSnapshot } from '../store/open-cleo-db.js'; import { + clearRowIdentityRefusal, fullRefillPlan, ROW_IDENTITY_META_TABLE, ROW_IDENTITY_REFILL_SNAPSHOT_KEY, + readRowIdentityRefusal, rowIdentityRecipeCurrent, rowIdentityShareState, shareStateOf, @@ -135,6 +137,7 @@ function readLocal(dbPath: string): { recipeCurrent: boolean; planned: Record; priorSnapshot: string | null; + refused: boolean; } { const snap = openCleoDbSnapshot(dbPath, { readOnly: true }); try { @@ -143,6 +146,7 @@ function readLocal(dbPath: string): { recipeCurrent: rowIdentityRecipeCurrent(snap.db), planned: fullRefillPlan(snap.db), priorSnapshot: lastRefillSnapshot(snap.db), + refused: readRowIdentityRefusal(snap.db)?.state === 'refused', }; } finally { snap.close(); @@ -179,9 +183,18 @@ export async function rowIdentityRefill( applied: false, snapshot: null, undo: null, + refusalCleared: false, }; } - const { local, recipeCurrent, planned, priorSnapshot } = readLocal(dbPath); + const { local, recipeCurrent, planned, priorSnapshot, refused } = readLocal(dbPath); + // T13305: an explicit --refill re-evaluates: a recorded refusal is cleared, + // so the next open (or the --apply below) decides afresh. + let refusalCleared = false; + if (refused) { + const handle = getDualScopeNativeDb(await openDualScopeDb('project', projectRoot)); + await import('../store/sqlite-data-accessor.js'); + refusalCleared = clearRowIdentityRefusal(handle); + } const probe = options.probe ?? (async (root: string) => @@ -203,6 +216,7 @@ export async function rowIdentityRefill( applied: false, snapshot: null, undo: null, + refusalCleared, }; if (options.apply !== true || action !== 'refill' || !fillEnabled) return base; const nexusCheckedNone = nexus.length > 0 && nexus.every((a) => a.answer === 'none'); diff --git a/packages/core/src/doctor/row-identity.ts b/packages/core/src/doctor/row-identity.ts index 1c576e2dd7..5e9f539421 100644 --- a/packages/core/src/doctor/row-identity.ts +++ b/packages/core/src/doctor/row-identity.ts @@ -23,7 +23,9 @@ import { ROW_IDENTITY, type RowIdentityFindings, type RowIdentityHealReceipt, + type RowIdentityRefusal, readRowIdentityHealReceipt, + readRowIdentityRefusal, rowIdentityFindings, UID_COLUMN, } from '../store/row-identity.js'; @@ -84,6 +86,7 @@ export function rowIdentityDoctorCheck(projectRoot: string): RowIdentityDoctorCh let unfilled: Record; let findings: RowIdentityFindings; let held: Record = {}; + let refusal: RowIdentityRefusal | null = null; try { const snap = openCleoDbSnapshot(dbPath, { readOnly: true }); try { @@ -103,6 +106,7 @@ export function rowIdentityDoctorCheck(projectRoot: string): RowIdentityDoctorCh } findings = rowIdentityFindings(snap.db, 'project'); held = heldRowCounts(snap.db); + refusal = readRowIdentityRefusal(snap.db); } finally { snap.close(); } @@ -120,6 +124,10 @@ export function rowIdentityDoctorCheck(projectRoot: string): RowIdentityDoctorCh const heldTotal = Object.values(held).reduce((a, n) => a + n, 0); const parts = [ missing.length > 0 ? schemaNote : '', + // T13305: a refused full refill is reported here, not on every open. + refusal?.state === 'refused' + ? `identity refill refused (${refusal.shareState}: ${refusal.reasons.join('; ')}); run \`cleo doctor row-identity --refill\`` + : '', // A row the store refused (invalid) needs a person; the rest wait for sync. (held.invalid ?? 0) > 0 ? `received rows refused as invalid: ${held.invalid}` : '', Object.keys(unfilled).length > 0 @@ -135,7 +143,7 @@ export function rowIdentityDoctorCheck(projectRoot: string): RowIdentityDoctorCh ? `symmetric relations stored in both directions (duplicates to drop): ${list(findings.mirrorEdges)}` : '', ].filter(Boolean); - const details = { dbPath, unfilled, findings, held, ...schemaDetails }; + const details = { dbPath, unfilled, findings, held, refusal, ...schemaDetails }; const uids = heldTotal > 0 ? `every row has a uid; rows held for sync: ${list(held)}` diff --git a/packages/core/src/store/__tests__/row-identity-full-refill.test.ts b/packages/core/src/store/__tests__/row-identity-full-refill.test.ts index 5aa96dd5a6..e75b650067 100644 --- a/packages/core/src/store/__tests__/row-identity-full-refill.test.ts +++ b/packages/core/src/store/__tests__/row-identity-full-refill.test.ts @@ -15,16 +15,20 @@ * @task T13231 */ -import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { DatabaseSync } from 'node:sqlite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { prepareRowIdentity, ROW_IDENTITY, + ROW_IDENTITY_RECIPE, ROW_IDENTITY_RECIPE_KEY, ROW_IDENTITY_REFILL_SNAPSHOT_KEY, + ROW_IDENTITY_REFUSED_KEY, + readRowIdentityRefusal, rowIdentityColumns, + rowIdentityFillPending, rowIdentityShareState, } from '../row-identity.js'; import { getNativeTasksDb } from '../sqlite.js'; @@ -229,4 +233,71 @@ describe('full from-scratch identity refill (T13231)', () => { expect(rowIdentityShareState(db).state).toBe('unshared'); }); }); + + describe('a refused refill is recorded: later opens are fast and quiet (T13305)', () => { + function linkNoVault(): void { + writeFileSync( + join(env.cleoDir, 'nexus-link.json'), + JSON.stringify({ version: 1, links: { 'https://api.example': { remoteProjectId: 'p1' } } }), + ); + } + + it('the second open takes the fast path; a NULL-uid row is filled without re-warning', () => { + linkNoVault(); + plantStale(); + expect(prepareRowIdentity(db, 'project')?.refill).toBe('refused'); + const first = readRowIdentityRefusal(db); + expect(first).toMatchObject({ + state: 'refused', + recipe: ROW_IDENTITY_RECIPE, + shareState: 'unknown', + }); + // Nothing else pending: the stale marker is settled by the refusal. + expect(rowIdentityFillPending(db, 'project')).toEqual([]); + expect(prepareRowIdentity(db, 'project')?.refill).toBe('none'); + // New work (a row without a uid) runs the pass; the refusal is not logged again. + db.exec( + "INSERT INTO tasks_task_relations (task_id, related_to, relation_type) VALUES ('T002', 'T001', 'blocks')", + ); + db.exec("UPDATE tasks_task_relations SET uid = NULL WHERE relation_type = 'blocks'"); + expect(rowIdentityFillPending(db, 'project')).toContain('uid:tasks_task_relations'); + expect(prepareRowIdentity(db, 'project')?.refill).toBe('refused'); + expect(readRowIdentityRefusal(db)?.warnedAt).toBe(first?.warnedAt); + expect(relUid()).toBe(BOGUS_REL); + }); + + it('a change of the share verdict re-evaluates: unlinked, the store refills', () => { + linkNoVault(); + plantStale(); + expect(prepareRowIdentity(db, 'project')?.refill).toBe('refused'); + rmSync(join(env.cleoDir, 'nexus-link.json')); + expect(rowIdentityFillPending(db, 'project')).toContain('recipe'); + expect(prepareRowIdentity(db, 'project')?.refill).toBe('cleared'); + expect(relUid()).not.toBe(BOGUS_REL); + }); + + it('a refusal recorded under another recipe is not settled', () => { + linkNoVault(); + plantStale(); + prepareRowIdentity(db, 'project'); + const prior = readRowIdentityRefusal(db); + db.prepare('UPDATE tasks_row_identity_meta SET value = ? WHERE key = ?').run( + JSON.stringify({ ...prior, recipe: 'cleo/row-identity/v1' }), + ROW_IDENTITY_REFUSED_KEY, + ); + expect(rowIdentityFillPending(db, 'project')).toContain('recipe'); + }); + + it('cleo doctor row-identity --refill (dry run) clears the refusal and re-evaluates', async () => { + linkNoVault(); + plantStale(); + prepareRowIdentity(db, 'project'); + expect(rowIdentityFillPending(db, 'project')).toEqual([]); + const { rowIdentityRefill } = await import('../../doctor/row-identity-refill.js'); + const report = await rowIdentityRefill(env.tempDir, { probe: async () => [] }); + expect(report.refusalCleared).toBe(true); + expect(readRowIdentityRefusal(db)?.state).toBe('cleared'); + expect(rowIdentityFillPending(db, 'project')).toContain('recipe'); + }); + }); }); diff --git a/packages/core/src/store/row-identity.ts b/packages/core/src/store/row-identity.ts index 7d7f49559a..bfb9c077ec 100644 --- a/packages/core/src/store/row-identity.ts +++ b/packages/core/src/store/row-identity.ts @@ -1815,16 +1815,126 @@ function resetStaleIdentity( ); return 'cleared'; } - getLogger('row-identity').error( - { scope, state: share.state, reasons: share.reasons }, - share.state === 'shared' - ? 'identity values predate the current recipe but may have left this store; kept as they are' - : 'identity values predate the current recipe and the store cannot be proven unshared; kept as they are. ' + - 'Remedy: `cleo doctor row-identity --refill` asks Cleo Nexus and shows the plan', - ); + // T13305: the refusal is recorded, so later opens take the fast path while + // nothing changed, and it is said at most once a day (a doctor finding + // carries it in between). + const key = shareKeyOf(share); + const prior = readRowIdentityRefusal(db); + const now = Date.now(); + const sameRefusal = + prior?.state === 'refused' && prior.recipe === ROW_IDENTITY_RECIPE && prior.shareKey === key; + const warnDue = + !sameRefusal || prior === null || now - Date.parse(prior.warnedAt) >= REFUSAL_WARN_INTERVAL_MS; + if (warnDue) { + getLogger('row-identity').warn( + { scope, state: share.state, reasons: share.reasons }, + share.state === 'shared' + ? 'identity values predate the current recipe but may have left this store; kept as they are' + : 'identity values predate the current recipe and the store cannot be proven unshared; kept as they are. ' + + 'Remedy: `cleo doctor row-identity --refill` asks Cleo Nexus and shows the plan', + ); + } + if (!sameRefusal || warnDue) { + const marker: RowIdentityRefusal = { + state: 'refused', + recipe: ROW_IDENTITY_RECIPE, + shareKey: key, + shareState: share.state, + reasons: [...share.reasons], + at: sameRefusal && prior ? prior.at : new Date(now).toISOString(), + warnedAt: warnDue + ? new Date(now).toISOString() + : (prior?.warnedAt ?? new Date(now).toISOString()), + }; + writers.writeRowIdentityMetaNative(db, ROW_IDENTITY_REFUSED_KEY, JSON.stringify(marker)); + } return 'refused'; } +/** Meta key recording a refused full refill (T13305). */ +export const ROW_IDENTITY_REFUSED_KEY = 'row_identity_refused'; + +/** How often a standing refusal is logged again (the doctor reports it in between). */ +export const REFUSAL_WARN_INTERVAL_MS = 24 * 60 * 60 * 1000; + +/** + * A recorded refusal of the full identity refill (T13305). While `state` is + * `refused` for the current recipe and the store's share signals are + * unchanged (`shareKey`), opens skip the refill pass. `cleared` means it was + * reset (`cleo doctor row-identity --refill`, or a refill that ran). + */ +export interface RowIdentityRefusal { + readonly state: 'refused' | 'cleared'; + readonly recipe: string; + /** Digest of the share verdict and its signals when refused. */ + readonly shareKey: string; + readonly shareState: 'shared' | 'unknown' | 'unshared'; + readonly reasons: readonly string[]; + /** When the refusal was first recorded. */ + readonly at: string; + /** When it was last logged. */ + readonly warnedAt: string; +} + +/** Digest of a share verdict: a change of state or of any signal re-evaluates a refusal. */ +function shareKeyOf(share: RowIdentityShareState): string { + return createHash('sha256') + .update(`${share.state}\n${share.signals.map((s) => `${s.code}:${s.detail}`).join('\n')}`) + .digest('hex') + .slice(0, 32); +} + +/** + * The store's recorded refusal of the full identity refill, if any. + * + * @param db - Connection on a project `cleo.db` (read-only is fine). + * @returns The refusal record, or `null`. + * @task T13305 + */ +export function readRowIdentityRefusal(db: DatabaseSync): RowIdentityRefusal | null { + if (!hasTable(db, ROW_IDENTITY_META_TABLE)) return null; + const raw = readMeta(db, ROW_IDENTITY_REFUSED_KEY); + if (raw === undefined) return null; + try { + return JSON.parse(raw) as RowIdentityRefusal; + } catch { + return null; + } +} + +/** + * Whether a recorded refusal still holds: refused for the current recipe, and + * the share verdict unchanged. Then the stale marker is not open-time work. + */ +function refusalHolds(db: DatabaseSync): boolean { + const prior = readRowIdentityRefusal(db); + if (prior?.state !== 'refused' || prior.recipe !== ROW_IDENTITY_RECIPE) return false; + return prior.shareKey === shareKeyOf(rowIdentityShareState(db)); +} + +/** + * Clear a recorded refusal, so the next open (or this caller) re-evaluates + * the full refill (`cleo doctor row-identity --refill`, T13305). + * + * @param db - Connection on a project `cleo.db`. + * @param writers - The chokepoint writers. + * @returns Whether a standing refusal was cleared. + * @task T13305 + */ +export function clearRowIdentityRefusal( + db: DatabaseSync, + writers: RowIdentityWriters = requireWriters(), +): boolean { + const prior = readRowIdentityRefusal(db); + if (prior?.state !== 'refused') return false; + writers.writeRowIdentityMetaNative( + db, + ROW_IDENTITY_REFUSED_KEY, + JSON.stringify({ ...prior, state: 'cleared' } satisfies RowIdentityRefusal), + ); + return true; +} + /** * Record that the store's identity values follow {@link ROW_IDENTITY_RECIPE}. * The fill writes it; {@link markRowIdentityShared} writes it too on the @@ -2149,9 +2259,15 @@ export function rowIdentityFillPending(db: DatabaseSync, scope: TableScope): str } const pending: string[] = []; // A stale marker is work even when the identity has synced: the pass then - // refuses the refill and says so (refill 'refused'), every open, until the - // store is repaired — that anomaly must stay loud, not become a fast path. - if (scope === 'project' && readMeta(db, ROW_IDENTITY_RECIPE_KEY) !== ROW_IDENTITY_RECIPE) { + // refuses the refill and records it; the record keeps later opens fast and + // quiet until the recipe or the share verdict changes (T13305). + // T13305: a refusal recorded for this recipe, with the share verdict + // unchanged, is settled: the pass would only refuse again. + if ( + scope === 'project' && + readMeta(db, ROW_IDENTITY_RECIPE_KEY) !== ROW_IDENTITY_RECIPE && + !refusalHolds(db) + ) { pending.push('recipe'); } if ( From 5ae08914e4ef70b68ee82e86e899f0e081b55289 Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Tue, 6 Oct 2026 18:57:13 -0700 Subject: [PATCH 2/4] feat(T13305): CLEO_ROW_UID_FILL on by default; =0 is the kill switch C2: the real-store Gate B of the release recipe passed on every store, cleocode included (all 7 checks on a sandbox copy of snapshot-20261006-121748). rowUidFillEnabled is now true unless CLEO_ROW_UID_FILL=0. Tests: row-identity-flag.test.ts (unset = on, 0 = off, other values = on). Tests written to exercise the fill-OFF path now set the kill switch explicitly (row-identity-off, -heal, -import 'uids off', -steady-open fill-off, doctor refill 'fill off'). The sync journal tests (capture, sealer, repair) were written against fill off and are pinned to it; the capture + fill-on interplay (real uid triggers emitting K captures) is a separate task. 646 identity tests + 101 sync tests pass. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/row-identity-refill.test.ts | 2 +- .../store/__tests__/row-identity-flag.test.ts | 32 +++++++++++++++++++ .../store/__tests__/row-identity-heal.test.ts | 3 +- .../__tests__/row-identity-import.test.ts | 2 +- .../store/__tests__/row-identity-off.test.ts | 5 +-- .../row-identity-steady-open.test.ts | 2 +- packages/core/src/store/row-identity-flag.ts | 17 ++++++---- .../src/store/sync/__tests__/capture.test.ts | 3 ++ .../src/store/sync/__tests__/repair.test.ts | 3 ++ .../src/store/sync/__tests__/sealer.test.ts | 3 ++ 10 files changed, 59 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/store/__tests__/row-identity-flag.test.ts diff --git a/packages/core/src/doctor/__tests__/row-identity-refill.test.ts b/packages/core/src/doctor/__tests__/row-identity-refill.test.ts index 43507b6d90..fa1367ce04 100644 --- a/packages/core/src/doctor/__tests__/row-identity-refill.test.ts +++ b/packages/core/src/doctor/__tests__/row-identity-refill.test.ts @@ -156,7 +156,7 @@ describe('cleo doctor row-identity --refill (T13231)', () => { }); it('--apply is refused while row uids are off in the process', async () => { - delete process.env.CLEO_ROW_UID_FILL; + process.env.CLEO_ROW_UID_FILL = '0'; const report = await rowIdentityRefill(env.tempDir, { apply: true, probe: async () => [answer({})], diff --git a/packages/core/src/store/__tests__/row-identity-flag.test.ts b/packages/core/src/store/__tests__/row-identity-flag.test.ts new file mode 100644 index 0000000000..e3b940d681 --- /dev/null +++ b/packages/core/src/store/__tests__/row-identity-flag.test.ts @@ -0,0 +1,32 @@ +/** + * T13305 (C2): row uids are ON by default; `CLEO_ROW_UID_FILL=0` is the kill + * switch, and any other value (or none) leaves them on. + * + * @task T13305 + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { defaultRowUid, ROW_UID_FILL_FLAG, rowUidFillEnabled } from '../row-identity-flag.js'; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('row uid default (T13305)', () => { + it('unset: on, and new minted rows get a uid', () => { + vi.stubEnv(ROW_UID_FILL_FLAG, undefined); + expect(rowUidFillEnabled()).toBe(true); + expect(typeof defaultRowUid()).toBe('string'); + }); + + it('CLEO_ROW_UID_FILL=0 is the kill switch: off, and new rows get NULL', () => { + vi.stubEnv(ROW_UID_FILL_FLAG, '0'); + expect(rowUidFillEnabled()).toBe(false); + expect(typeof defaultRowUid()).not.toBe('string'); + }); + + it.each(['1', 'true', ''])('CLEO_ROW_UID_FILL=%j leaves them on', (value) => { + vi.stubEnv(ROW_UID_FILL_FLAG, value); + expect(rowUidFillEnabled()).toBe(true); + }); +}); diff --git a/packages/core/src/store/__tests__/row-identity-heal.test.ts b/packages/core/src/store/__tests__/row-identity-heal.test.ts index c5ae4701fb..4b1fc5b94d 100644 --- a/packages/core/src/store/__tests__/row-identity-heal.test.ts +++ b/packages/core/src/store/__tests__/row-identity-heal.test.ts @@ -52,7 +52,8 @@ let testDir: string; let dbPath: string; beforeEach(() => { - delete process.env.CLEO_ROW_UID_FILL; + // Row uids off (the kill switch; on by default since T13305): the heal runs anyway. + process.env.CLEO_ROW_UID_FILL = '0'; testDir = realpathSync(mkdtempSync(join(tmpdir(), 'cleo-row-identity-heal-T12878-'))); dbPath = join(testDir, '.cleo', 'cleo.db'); }); diff --git a/packages/core/src/store/__tests__/row-identity-import.test.ts b/packages/core/src/store/__tests__/row-identity-import.test.ts index b7d9bb6fca..52b00b35a2 100644 --- a/packages/core/src/store/__tests__/row-identity-import.test.ts +++ b/packages/core/src/store/__tests__/row-identity-import.test.ts @@ -546,7 +546,7 @@ describe('row uids off (T12806 review 1)', () => { let db: DatabaseSync; beforeEach(async () => { - delete process.env.CLEO_ROW_UID_FILL; + process.env.CLEO_ROW_UID_FILL = '0'; env = await createTestDb(); await seedTasks(env.accessor, [ { id: 'T001', title: 'A', type: 'task', createdAt: '2026-09-01T10:00:00.000Z' }, diff --git a/packages/core/src/store/__tests__/row-identity-off.test.ts b/packages/core/src/store/__tests__/row-identity-off.test.ts index b104550ccc..a0aa2eb8d5 100644 --- a/packages/core/src/store/__tests__/row-identity-off.test.ts +++ b/packages/core/src/store/__tests__/row-identity-off.test.ts @@ -12,16 +12,17 @@ import { prepareRowIdentity } from '../row-identity.js'; import { getNativeTasksDb } from '../sqlite.js'; import { createTestDb, seedTasks, type TestDbEnv } from './test-db-helper.js'; -describe('row uids are off by default', () => { +describe('row uids off with the kill switch (CLEO_ROW_UID_FILL=0; on by default since T13305)', () => { let env: TestDbEnv; beforeEach(async () => { - delete process.env.CLEO_ROW_UID_FILL; + process.env.CLEO_ROW_UID_FILL = '0'; env = await createTestDb(); await seedTasks(env.accessor, [{ id: 'T001', title: 'Root', type: 'task', labels: ['a'] }]); }); afterEach(async () => { + delete process.env.CLEO_ROW_UID_FILL; await env.cleanup(); }); diff --git a/packages/core/src/store/__tests__/row-identity-steady-open.test.ts b/packages/core/src/store/__tests__/row-identity-steady-open.test.ts index 08f6c012d5..ab84a99f51 100644 --- a/packages/core/src/store/__tests__/row-identity-steady-open.test.ts +++ b/packages/core/src/store/__tests__/row-identity-steady-open.test.ts @@ -132,7 +132,7 @@ describe('row identity on a completed store (T12341 C1)', () => { .all() as Array<{ name: string }> ).map((r) => r.name); expect(indexes(native())).toContain('idx_tasks_tasks_birth_fp'); - delete process.env.CLEO_ROW_UID_FILL; + process.env.CLEO_ROW_UID_FILL = '0'; const off = await createTestDb(); try { await seedTasks(off.accessor, [{ id: 'T001', title: 'Off', type: 'task' }]); diff --git a/packages/core/src/store/row-identity-flag.ts b/packages/core/src/store/row-identity-flag.ts index 7dc4969813..5b50bb3c00 100644 --- a/packages/core/src/store/row-identity-flag.ts +++ b/packages/core/src/store/row-identity-flag.ts @@ -1,26 +1,29 @@ /** - * Opt-in switch for row uids (T12341). OFF by default until a real-store - * Gate B of the release recipe passes in CI: with it off, the open pass does - * not run, no per-connection trigger is installed, and new rows get no uid. - * Kept tiny so the Drizzle schema can use {@link defaultRowUid}. + * Switch for row uids (T12341). ON by default since T13305 (C2): the + * real-store Gate B of the release recipe passed on every store (spec + * `t12341-uid-scheme` §15.0). `CLEO_ROW_UID_FILL=0` is the kill switch: with it, + * the open pass does not run, no per-connection trigger is installed, and new + * rows get no uid. Kept tiny so the Drizzle schema can use + * {@link defaultRowUid}. * * @module * @task T12341 + * @task T13305 */ import { type SQL, sql } from 'drizzle-orm'; import { uuidv7 } from '../cloud/uuidv7.js'; -/** Environment variable that turns row uids ON (`1`). */ +/** Environment variable whose value `0` turns row uids OFF (the kill switch; T13305). */ export const ROW_UID_FILL_FLAG = 'CLEO_ROW_UID_FILL'; /** * Whether row uids are enabled for this process. * - * @returns `true` only when {@link ROW_UID_FILL_FLAG} is `1`. + * @returns `false` only when {@link ROW_UID_FILL_FLAG} is `0` (on by default). */ export function rowUidFillEnabled(): boolean { - return process.env[ROW_UID_FILL_FLAG] === '1'; + return process.env[ROW_UID_FILL_FLAG] !== '0'; } /** diff --git a/packages/core/src/store/sync/__tests__/capture.test.ts b/packages/core/src/store/sync/__tests__/capture.test.ts index 89a9b0017a..cd5c86afb9 100644 --- a/packages/core/src/store/sync/__tests__/capture.test.ts +++ b/packages/core/src/store/sync/__tests__/capture.test.ts @@ -118,6 +118,9 @@ beforeEach(() => { vi.stubEnv('CLEO_HOME', join(dir, 'cleo')); vi.stubEnv('CLEO_ROOT', undefined); vi.stubEnv('CLEO_DIR', undefined); + // Written against row uids off; on by default since T13305 (C2). The + // capture + fill-on interplay is tracked separately (see the C2 PR). + vi.stubEnv('CLEO_ROW_UID_FILL', '0'); dbPath = join(dir, 'project', '.cleo', 'cleo.db'); }); diff --git a/packages/core/src/store/sync/__tests__/repair.test.ts b/packages/core/src/store/sync/__tests__/repair.test.ts index 3268dbb215..bbfe09f368 100644 --- a/packages/core/src/store/sync/__tests__/repair.test.ts +++ b/packages/core/src/store/sync/__tests__/repair.test.ts @@ -52,6 +52,9 @@ beforeEach(() => { vi.stubEnv('CLEO_HOME', join(dir, 'cleo')); vi.stubEnv('CLEO_ROOT', undefined); vi.stubEnv('CLEO_DIR', undefined); + // Written against row uids off; on by default since T13305 (C2). The + // capture + fill-on interplay is tracked separately (see the C2 PR). + vi.stubEnv('CLEO_ROW_UID_FILL', '0'); dbPath = join(dir, 'project', '.cleo', 'cleo.db'); }); diff --git a/packages/core/src/store/sync/__tests__/sealer.test.ts b/packages/core/src/store/sync/__tests__/sealer.test.ts index 35cfd887c0..3689d5c915 100644 --- a/packages/core/src/store/sync/__tests__/sealer.test.ts +++ b/packages/core/src/store/sync/__tests__/sealer.test.ts @@ -60,6 +60,9 @@ beforeEach(() => { vi.stubEnv('CLEO_HOME', join(dir, 'cleo')); vi.stubEnv('CLEO_ROOT', undefined); vi.stubEnv('CLEO_DIR', undefined); + // Written against row uids off; on by default since T13305 (C2). The + // capture + fill-on interplay is tracked separately (see the C2 PR). + vi.stubEnv('CLEO_ROW_UID_FILL', '0'); dbPath = join(dir, 'project', '.cleo', 'cleo.db'); }); From c18c611a3e491a8b88f38d256fb7279f817f9ef4 Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Tue, 6 Oct 2026 19:25:19 -0700 Subject: [PATCH 3/4] fix(store): bundle import marks received identity; export failure names the project (T13305) Folds the #1952 review LOWs into C2: - LOW-1: a placed project cleo.db that carries row uids records that it received them (pre-T13250 bundles carry no marker), through a direct no-migration open of the placed file. - LOW-2: a machine export that cannot mark one project fails with E_PROJECT_STORE_UNAVAILABLE naming that project. Changeset documents the default-on flip and the CLEO_ROW_UID_FILL=0 kill switch. Co-Authored-By: Claude Opus 5.5 --- .changeset/row-uid-fill-default-on.md | 36 ++++++++ .../store/__tests__/identity-share.test.ts | 85 ++++++++++++++++++- packages/core/src/store/identity-share.ts | 37 ++++++++ .../core/src/store/portable-bundle-import.ts | 7 ++ packages/core/src/store/portable-bundle.ts | 15 +++- 5 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 .changeset/row-uid-fill-default-on.md diff --git a/.changeset/row-uid-fill-default-on.md b/.changeset/row-uid-fill-default-on.md new file mode 100644 index 0000000000..2a9b3090d4 --- /dev/null +++ b/.changeset/row-uid-fill-default-on.md @@ -0,0 +1,36 @@ +--- +id: row-uid-fill-default-on +tasks: [T13305, T12341] +kind: feat +summary: "Row uids fill by default; CLEO_ROW_UID_FILL=0 is the kill switch (canary only)" +--- + +The open-time row-identity pass (T12341) now runs by default. Opening a +project or global `cleo.db` gives every syncing row a stable `row_uid` and +stamps the `cleo/row-identity/v2` recipe marker. **Kill switch:** set +`CLEO_ROW_UID_FILL=0` to turn the pass off. Any other value, or no value, +leaves it on. Ship this through the canary channel first. + +A store with a stale recipe whose share state is not `unshared` (for example +one with pre-release uids that is linked to Nexus) is refused a full refill, +as before (T13231). The refusal is now cheap and quiet: + +- The refusal is saved in the store as a local-only `row_identity_refused` + marker, recording the share state, recipe, a digest of the share signals, + the reasons and the time of the last warning. +- Later opens skip the pass and log nothing. A warning is logged once a day + at most. +- The pass is re-evaluated only when the recipe or the share signals change, + or on `cleo doctor row-identity --refill` (the dry run too), which clears + the marker and reports `refusalCleared`. +- `cleo doctor` shows the standing refusal as a finding. + +Two follow-ups from the #1952 review: + +- Importing or vault-restoring a portable bundle whose project store carries + row uids now marks the placed store as having received them. This includes + bundles made before T13250, which carry no marker, so a later refill never + re-derives uids the bundle shares. The import opens the placed file + directly and never migrates it. +- When a machine export cannot mark one project (busy or restoring), it fails + with `E_PROJECT_STORE_UNAVAILABLE` and names that project. diff --git a/packages/core/src/store/__tests__/identity-share.test.ts b/packages/core/src/store/__tests__/identity-share.test.ts index a5529d393e..23abcc2670 100644 --- a/packages/core/src/store/__tests__/identity-share.test.ts +++ b/packages/core/src/store/__tests__/identity-share.test.ts @@ -9,10 +9,13 @@ * @task T13250 */ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import type { DatabaseSync } from 'node:sqlite'; +import { DatabaseSync } from 'node:sqlite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { exportPortableBundle } from '../portable-bundle.js'; +import { exportPortableBundle, PortableBundleError } from '../portable-bundle.js'; +import { importPortableBundle } from '../portable-bundle-import.js'; import { prepareRowIdentity, ROW_IDENTITY_RECIPE_KEY, @@ -89,6 +92,57 @@ describe('portable bundle exports mark identity shared (T13250)', () => { await exportBundle(false); expect(synced()).toBeUndefined(); }); + + it('importing a bundle that carries uids but no marker marks the placed store received (#1952 LOW-1)', async () => { + // A bundle made before T13250 has the uids and no synced marker; the + // unmarked export reproduces that shape exactly. + const bundle = (await exportBundle(false)).bundlePath; + expect(synced()).toBeUndefined(); + const dest = mkdtempSync(join(tmpdir(), 'cleo-t13305-import-')); + try { + const target = join(dest, 'moved'); + const imported = await importPortableBundle({ + bundlePath: bundle, + cwd: '/', + target, + cleoHome: join(dest, 'home'), + configHome: join(dest, 'config'), + }); + expect(imported.lossless).toBe(true); + const placed = new DatabaseSync(join(target, '.cleo', 'cleo.db'), { readOnly: true }); + try { + const row = placed + .prepare('SELECT value FROM tasks_row_identity_meta WHERE key = ?') + .get(ROW_IDENTITY_SYNCED_KEY) as { value: string } | undefined; + expect(JSON.parse(String(row?.value)).first).toBe('receive'); + } finally { + placed.close(); + } + } finally { + rmSync(dest, { recursive: true, force: true }); + } + }); + + it('a machine export that cannot mark a project names that project (#1952 LOW-2)', async () => { + const share = await import('../identity-share.js'); + const spy = vi + .spyOn(share, 'markProjectIdentityShared') + .mockRejectedValue(new Error('store is restoring')); + try { + const err = await exportPortableBundle({ + scope: 'machine', + outputPath: join(env.tempDir, 'out', 'm.cleobundle.tar.gz'), + label: 'm', + isTempPath: () => false, + }).catch((e: Error) => e); + expect(err).toBeInstanceOf(PortableBundleError); + expect((err as PortableBundleError).code).toBe('E_PROJECT_STORE_UNAVAILABLE'); + expect((err as Error).message).toContain(env.tempDir); + expect((err as Error).message).toContain('store is restoring'); + } finally { + spy.mockRestore(); + } + }); }); describe('a store with no identity is never touched (T13250)', () => { @@ -96,6 +150,33 @@ describe('a store with no identity is never touched (T13250)', () => { await setup(false); }); + it('importing a bundle from a store with no uids marks nothing', async () => { + const bundle = (await exportBundle()).bundlePath; + const dest = mkdtempSync(join(tmpdir(), 'cleo-t13305-import-')); + try { + const target = join(dest, 'moved'); + await importPortableBundle({ + bundlePath: bundle, + cwd: '/', + target, + cleoHome: join(dest, 'home'), + configHome: join(dest, 'config'), + }); + const placed = new DatabaseSync(join(target, '.cleo', 'cleo.db'), { readOnly: true }); + try { + expect( + placed + .prepare('SELECT value FROM tasks_row_identity_meta WHERE key = ?') + .get(ROW_IDENTITY_SYNCED_KEY), + ).toBeUndefined(); + } finally { + placed.close(); + } + } finally { + rmSync(dest, { recursive: true, force: true }); + } + }); + it('a bundle export of a fill-off store writes no identity meta', async () => { const before = db.prepare('SELECT count(*) AS n FROM tasks_row_identity_meta').get(); await exportBundle(); diff --git a/packages/core/src/store/identity-share.ts b/packages/core/src/store/identity-share.ts index d13e8b0a62..48ed3ab89c 100644 --- a/packages/core/src/store/identity-share.ts +++ b/packages/core/src/store/identity-share.ts @@ -66,3 +66,40 @@ export async function markProjectIdentityShared( markRowIdentityShared(db, direction); return true; } + +/** + * Mark a store FILE's identity shared without opening it through the + * chokepoint (no migration, no open-time pass): for a store a bundle import + * or vault restore just placed, which import must preserve as it is. Only a + * store that holds identity values and the identity meta table is touched. + * A bundle made before T13250, or by an older peer, carries uids with no + * marker; the placed copy records that it received them (#1952 review LOW-1). + * + * @param dbPath - The placed project `cleo.db`. + * @param direction - `receive` for a placed copy. + * @returns Whether the marker was written (or was already there). + * @task T13305 + */ +export async function markStoreFileIdentityShared( + dbPath: string, + direction: 'send' | 'receive', +): Promise { + if (!existsSync(dbPath)) return false; + const { openNativeDatabase } = await import('./sqlite-native.js'); + const { fullRefillPlan, markRowIdentityShared, ROW_IDENTITY_META_TABLE } = await import( + './row-identity.js' + ); + const db = openNativeDatabase(dbPath); + try { + const hasMeta = + db + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(ROW_IDENTITY_META_TABLE) !== undefined; + if (!hasMeta || Object.keys(fullRefillPlan(db)).length === 0) return false; + await import('./sqlite-data-accessor.js'); + markRowIdentityShared(db, direction); + return true; + } finally { + db.close(); + } +} diff --git a/packages/core/src/store/portable-bundle-import.ts b/packages/core/src/store/portable-bundle-import.ts index 4821ad0e9b..9dbb5b5ab8 100644 --- a/packages/core/src/store/portable-bundle-import.ts +++ b/packages/core/src/store/portable-bundle-import.ts @@ -697,6 +697,13 @@ export async function importPortableBundle( for (const d of plan.section.databases) { if (path.basename(d.relPath) === 'cleo.db') { ensureTriggerSuspendTableAtPath(path.join(plan.destDir, d.relPath)); + // T13305: a placed project store carrying row uids records that it + // received them (bundles made before T13250 carry no marker), so a + // later refill never re-derives uids the bundle shares. + if (plan.kind === 'project') { + const { markStoreFileIdentityShared } = await import('./identity-share.js'); + await markStoreFileIdentityShared(path.join(plan.destDir, d.relPath), 'receive'); + } } } const result: PortableImportSectionResult = { diff --git a/packages/core/src/store/portable-bundle.ts b/packages/core/src/store/portable-bundle.ts index e4669b5a4e..acbf21b26a 100644 --- a/packages/core/src/store/portable-bundle.ts +++ b/packages/core/src/store/portable-bundle.ts @@ -109,7 +109,8 @@ export type PortableBundleErrorCode = | 'E_DATA_EXISTS' | 'E_TARGET_AMBIGUOUS' | 'E_RESTORE_MISMATCH' - | 'E_REDACTION_FAILED'; + | 'E_REDACTION_FAILED' + | 'E_PROJECT_STORE_UNAVAILABLE'; /** * Numeric exit codes for {@link PortableBundleErrorCode}. Decrypt / format / @@ -118,6 +119,7 @@ export type PortableBundleErrorCode = */ export const PORTABLE_BUNDLE_EXIT_CODES: Readonly> = { E_PRIMARY_STORE_MISSING: ExitCode.NOT_FOUND, + E_PROJECT_STORE_UNAVAILABLE: ExitCode.LOCK_TIMEOUT, E_PRIMARY_STORE_UNREADABLE: ExitCode.FILE_ERROR, E_NO_PROJECT: ExitCode.NOT_FOUND, E_REGISTRY_UNREADABLE: ExitCode.FILE_ERROR, @@ -654,7 +656,16 @@ async function stageProject( // read-only and never touched. if (state.sharesIdentity) { const { markProjectIdentityShared } = await import('./identity-share.js'); - await markProjectIdentityShared(projectRoot, 'send', { onlyIfIdentity: true }); + try { + await markProjectIdentityShared(projectRoot, 'send', { onlyIfIdentity: true }); + } catch (err) { + // One busy or restoring project fails the whole export (T13270); the + // error names it, so the operator knows which one (#1952 review LOW-2). + throw new PortableBundleError( + 'E_PROJECT_STORE_UNAVAILABLE', + `cannot mark the identity of ${projectRoot} before bundling it: ${err instanceof Error ? err.message : String(err)}`, + ); + } } const info = await readProjectIdentity(projectRoot); const name = info.name; From d209d7d1485009e50b212a0e8b2ba875d10e9d66 Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Tue, 6 Oct 2026 20:04:14 -0700 Subject: [PATCH 4/4] test(T13305): fix the CI failures of the default-on flip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - schema-write guard coverage: the import-time marker open is DML only (exempt). - trigger-suspend: a table rebuild on a connection the open pass armed also drops its TEMP row-uid triggers (the next open re-arms them); the §13 rollback runs with the kill switch, as an operator rollback does. - migrate-signaldock: the agent-registry-store mock is partial, since the open pass now loads the store writers. - exodus-capture-bracket, recursive-triggers-audit, rewriters-capture: pinned off like capture/sealer/repair; K captures with fill on are T13311. - spawn, pipeline-e2e: pinned off; a TDZ under vi.resetModules with fill on is T13313 (the built CLI is unaffected). - Docs that called row uids opt-in now name the kill switch. Co-Authored-By: Claude Opus 5.5 --- .../src/cli/commands/doctor-row-identity.ts | 2 +- .../core/src/__tests__/pipeline-e2e.test.ts | 10 ++++++++++ .../src/orchestration/__tests__/spawn.test.ts | 10 ++++++++++ .../__tests__/exodus-capture-bracket.test.ts | 6 +++++- .../migrate-signaldock-to-conduit.test.ts | 5 ++++- packages/core/src/store/db-helpers.ts | 2 +- packages/core/src/store/display-id-alias.ts | 2 +- packages/core/src/store/dual-scope-db.ts | 4 ++-- packages/core/src/store/identity-share.ts | 2 +- packages/core/src/store/row-identity.ts | 7 ++++--- .../src/store/sync/__tests__/capture.test.ts | 2 +- .../__tests__/recursive-triggers-audit.test.ts | 3 +++ .../src/store/sync/__tests__/repair.test.ts | 2 +- .../sync/__tests__/rewriters-capture.test.ts | 3 +++ .../src/store/sync/__tests__/sealer.test.ts | 2 +- .../sync/__tests__/trigger-suspend.test.ts | 17 ++++++++++++----- 16 files changed, 60 insertions(+), 19 deletions(-) diff --git a/packages/cleo/src/cli/commands/doctor-row-identity.ts b/packages/cleo/src/cli/commands/doctor-row-identity.ts index 8d8911c471..a64629489a 100644 --- a/packages/cleo/src/cli/commands/doctor-row-identity.ts +++ b/packages/cleo/src/cli/commands/doctor-row-identity.ts @@ -36,7 +36,7 @@ export const doctorRowIdentityCommand = defineCommand({ type: 'boolean', description: 'With --refill: snapshot the store, then re-derive every identity value from scratch. ' + - 'Refused unless the verdict is unshared and CLEO_ROW_UID_FILL=1', + 'Refused unless the verdict is unshared, and while CLEO_ROW_UID_FILL=0', }, json: { type: 'boolean', description: 'Output as JSON' }, human: { type: 'boolean', description: 'Force human-readable output' }, diff --git a/packages/core/src/__tests__/pipeline-e2e.test.ts b/packages/core/src/__tests__/pipeline-e2e.test.ts index c2e481f596..e3df2863b3 100644 --- a/packages/core/src/__tests__/pipeline-e2e.test.ts +++ b/packages/core/src/__tests__/pipeline-e2e.test.ts @@ -163,6 +163,16 @@ async function makeTmpEnv(suffix: string): Promise { // Suite 1 — End-to-end pipeline for all 5 worker roles // --------------------------------------------------------------------------- +// Row uids are on by default since T13305 (C2). With the fill on, this +// vi.resetModules + vi.doMock harness hits a TDZ on dual-scope-db's cache +// (T13313); the built CLI is unaffected. Pinned off until T13313 lands. +beforeEach(() => { + vi.stubEnv('CLEO_ROW_UID_FILL', '0'); +}); +afterEach(() => { + vi.unstubAllEnvs(); +}); + describe('Pipeline E2E — all 5 worker role templates', () => { let env: TmpEnv; diff --git a/packages/core/src/orchestration/__tests__/spawn.test.ts b/packages/core/src/orchestration/__tests__/spawn.test.ts index 3a7b6b1d5c..1c5b290421 100644 --- a/packages/core/src/orchestration/__tests__/spawn.test.ts +++ b/packages/core/src/orchestration/__tests__/spawn.test.ts @@ -195,6 +195,16 @@ const BASE_TASK: Task = { // Suite // --------------------------------------------------------------------------- +// Row uids are on by default since T13305 (C2). With the fill on, this +// vi.resetModules + vi.doMock harness hits a TDZ on dual-scope-db's cache +// (T13313); the built CLI is unaffected. Pinned off until T13313 lands. +beforeEach(() => { + vi.stubEnv('CLEO_ROW_UID_FILL', '0'); +}); +afterEach(() => { + vi.unstubAllEnvs(); +}); + describe('composeSpawnPayload — full envelope', () => { let env: TmpEnv; diff --git a/packages/core/src/store/__tests__/exodus-capture-bracket.test.ts b/packages/core/src/store/__tests__/exodus-capture-bracket.test.ts index 4a89f98377..106a645735 100644 --- a/packages/core/src/store/__tests__/exodus-capture-bracket.test.ts +++ b/packages/core/src/store/__tests__/exodus-capture-bracket.test.ts @@ -18,7 +18,7 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { pathToFileURL } from 'node:url'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { _resetDualScopeDbCache, getDualScopeNativeDb, @@ -43,6 +43,9 @@ const captureTriggers = (db: DatabaseSync): number => ); beforeEach(() => { + // Written against row uids off; on by default since T13305 (C2). The + // capture + fill-on interplay (K captures alongside I/U/D) is T13311. + vi.stubEnv('CLEO_ROW_UID_FILL', '0'); dir = mkdtempSync(join(tmpdir(), 'cleo-exodus-capture-')); projectDbPath = join(dir, 'cleo.db'); const legacy = new DatabaseSync(join(dir, 'tasks.db')); @@ -54,6 +57,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); _resetDualScopeDbCache(); rmSync(dir, { recursive: true, force: true }); }); diff --git a/packages/core/src/store/__tests__/migrate-signaldock-to-conduit.test.ts b/packages/core/src/store/__tests__/migrate-signaldock-to-conduit.test.ts index a5d90c5b75..1a5ab652ca 100644 --- a/packages/core/src/store/__tests__/migrate-signaldock-to-conduit.test.ts +++ b/packages/core/src/store/__tests__/migrate-signaldock-to-conduit.test.ts @@ -339,7 +339,10 @@ async function runMigration( getGlobalSalt: () => Buffer.alloc(32, 0xab), __clearGlobalSaltCache: vi.fn(), })); - vi.doMock('../agent-registry-store.js', () => ({ + // Partial: the open pass (row uids on by default, T13305) loads the store + // writers, which import the rest of this module. + vi.doMock('../agent-registry-store.js', async (importOriginal) => ({ + ...(await importOriginal()), ensureGlobalSignaldockDb: vi.fn(async () => ({ action: 'exists', path: join(home, 'signaldock.db'), diff --git a/packages/core/src/store/db-helpers.ts b/packages/core/src/store/db-helpers.ts index 4a587ab131..8b391a8e87 100644 --- a/packages/core/src/store/db-helpers.ts +++ b/packages/core/src/store/db-helpers.ts @@ -118,7 +118,7 @@ async function tableExists(db: DrizzleDb, table: string): Promise { /** * The identity columns of an IMPORTED task (T12341 spec §5.1, T12806), only - * while row uids are on (`CLEO_ROW_UID_FILL=1`; off, nothing changes): + * while row uids are on (the default; with `CLEO_ROW_UID_FILL=0`, nothing changes): * * - the source's `uid` + `birth_fp`, followed through `tasks_uid_aliases` * when it was re-keyed here, when no row holds it; diff --git a/packages/core/src/store/display-id-alias.ts b/packages/core/src/store/display-id-alias.ts index af7b1523a2..589c7d5c39 100644 --- a/packages/core/src/store/display-id-alias.ts +++ b/packages/core/src/store/display-id-alias.ts @@ -631,7 +631,7 @@ export function remintTaskDisplayId( .get(taskId) as { uid: string | null; birthFp: string | null } | undefined; if (!row?.uid || !row.birthFp) { throw new CleoError(ExitCode.NOT_FOUND, `No task ${taskId} with a uid and birth fingerprint`, { - fix: 'Check the id with `cleo show`; row identity is filled when CLEO_ROW_UID_FILL=1.', + fix: 'Check the id with `cleo show`; row identity is not filled while CLEO_ROW_UID_FILL=0.', details: { field: 'taskId', actual: taskId }, }); } diff --git a/packages/core/src/store/dual-scope-db.ts b/packages/core/src/store/dual-scope-db.ts index bbe9c245c9..9cfb454458 100644 --- a/packages/core/src/store/dual-scope-db.ts +++ b/packages/core/src/store/dual-scope-db.ts @@ -945,7 +945,7 @@ async function openDedicatedDualScopeDb( execution?.assertActive(); await migrateScopeSchema(scope, nativeDb, db, migrationsFolder, log, execution); - // T12341: fill row uids (opt-in). No per-connection uid triggers: + // T12341: fill row uids (on unless CLEO_ROW_UID_FILL=0). No per-connection uid triggers: // dedicated handles run the exodus copy, whose effect inspection // refuses a trigger that calls an opaque function; the next open fills // its rows. The identity schema was healed in the schema pass; the @@ -1260,7 +1260,7 @@ export async function openDualScopeDbAtPath( // open, flag or not, before capture is installed. await migrateScopeSchema(scope, nativeDb, db, migrationsFolder, log, execution); - // T12341 (opt-in, CLEO_ROW_UID_FILL=1): fill every NULL row uid + // T12341 (on unless CLEO_ROW_UID_FILL=0, T13305): fill every NULL row uid // deterministically, inside this lease so two processes never fill at // once, and arm this connection's uid triggers. Never throws. execution?.assertActive(); diff --git a/packages/core/src/store/identity-share.ts b/packages/core/src/store/identity-share.ts index 48ed3ab89c..58fcdf723e 100644 --- a/packages/core/src/store/identity-share.ts +++ b/packages/core/src/store/identity-share.ts @@ -89,7 +89,7 @@ export async function markStoreFileIdentityShared( const { fullRefillPlan, markRowIdentityShared, ROW_IDENTITY_META_TABLE } = await import( './row-identity.js' ); - const db = openNativeDatabase(dbPath); + const db = openNativeDatabase(dbPath); // schema-guard-exempt: a store copy import just placed; writing the shared marker row is DML only try { const hasMeta = db diff --git a/packages/core/src/store/row-identity.ts b/packages/core/src/store/row-identity.ts index bfb9c077ec..e26064f34f 100644 --- a/packages/core/src/store/row-identity.ts +++ b/packages/core/src/store/row-identity.ts @@ -50,8 +50,9 @@ * * A row an older build inserts keeps NULL values until the next open by this * build: there is no persistent trigger that computes them (see the - * migration's header). Row uids are OPT-IN: nothing here runs unless - * `CLEO_ROW_UID_FILL=1` ({@link rowUidFillEnabled}). + * migration's header). Row uids are on by default (T13305); nothing here + * runs when the kill switch `CLEO_ROW_UID_FILL=0` is set + * ({@link rowUidFillEnabled}). * * A failure never fails the open: it is logged, and the rows keep NULL values * until a later open succeeds. A uid's timestamp is ordering sugar only: HLC @@ -1997,7 +1998,7 @@ export function markRowIdentityShared( * their indexes. No row value is read or written. A store whose uid migration * was journaled without running its statements (journal Scenario 3 Case A: * live cleocode under 9.25, spec §12.2) gets the full schema here; the fill, - * refill and TEMP triggers stay opt-in ({@link prepareRowIdentity}). + * refill and TEMP triggers follow the fill flag ({@link prepareRowIdentity}). * * Never throws: a failure is logged and the open continues. * diff --git a/packages/core/src/store/sync/__tests__/capture.test.ts b/packages/core/src/store/sync/__tests__/capture.test.ts index cd5c86afb9..1197884e67 100644 --- a/packages/core/src/store/sync/__tests__/capture.test.ts +++ b/packages/core/src/store/sync/__tests__/capture.test.ts @@ -119,7 +119,7 @@ beforeEach(() => { vi.stubEnv('CLEO_ROOT', undefined); vi.stubEnv('CLEO_DIR', undefined); // Written against row uids off; on by default since T13305 (C2). The - // capture + fill-on interplay is tracked separately (see the C2 PR). + // capture + fill-on interplay (K captures alongside I/U/D) is T13311. vi.stubEnv('CLEO_ROW_UID_FILL', '0'); dbPath = join(dir, 'project', '.cleo', 'cleo.db'); }); diff --git a/packages/core/src/store/sync/__tests__/recursive-triggers-audit.test.ts b/packages/core/src/store/sync/__tests__/recursive-triggers-audit.test.ts index abac34481d..396c50bf1e 100644 --- a/packages/core/src/store/sync/__tests__/recursive-triggers-audit.test.ts +++ b/packages/core/src/store/sync/__tests__/recursive-triggers-audit.test.ts @@ -38,6 +38,9 @@ beforeEach(() => { vi.stubEnv('CLEO_HOME', join(dir, 'cleo')); vi.stubEnv('CLEO_ROOT', undefined); vi.stubEnv('CLEO_DIR', undefined); + // Written against row uids off; on by default since T13305 (C2). The + // capture + fill-on interplay (K captures alongside I/U/D) is T13311. + vi.stubEnv('CLEO_ROW_UID_FILL', '0'); }); afterEach(() => { diff --git a/packages/core/src/store/sync/__tests__/repair.test.ts b/packages/core/src/store/sync/__tests__/repair.test.ts index bbfe09f368..5ab7d1709f 100644 --- a/packages/core/src/store/sync/__tests__/repair.test.ts +++ b/packages/core/src/store/sync/__tests__/repair.test.ts @@ -53,7 +53,7 @@ beforeEach(() => { vi.stubEnv('CLEO_ROOT', undefined); vi.stubEnv('CLEO_DIR', undefined); // Written against row uids off; on by default since T13305 (C2). The - // capture + fill-on interplay is tracked separately (see the C2 PR). + // capture + fill-on interplay (K captures alongside I/U/D) is T13311. vi.stubEnv('CLEO_ROW_UID_FILL', '0'); dbPath = join(dir, 'project', '.cleo', 'cleo.db'); }); diff --git a/packages/core/src/store/sync/__tests__/rewriters-capture.test.ts b/packages/core/src/store/sync/__tests__/rewriters-capture.test.ts index 26ed33485a..4682bb75a9 100644 --- a/packages/core/src/store/sync/__tests__/rewriters-capture.test.ts +++ b/packages/core/src/store/sync/__tests__/rewriters-capture.test.ts @@ -63,6 +63,9 @@ beforeEach(() => { vi.stubEnv('CLEO_HOME', join(root, 'cleo-home')); vi.stubEnv('CLEO_DIR', cleoDir); vi.stubEnv('CLEO_ROOT', undefined); + // Written against row uids off; on by default since T13305 (C2). The + // capture + fill-on interplay (K captures alongside I/U/D) is T13311. + vi.stubEnv('CLEO_ROW_UID_FILL', '0'); liveDb = join(cleoDir, 'cleo.db'); }); diff --git a/packages/core/src/store/sync/__tests__/sealer.test.ts b/packages/core/src/store/sync/__tests__/sealer.test.ts index 545c91dd38..70a025eddf 100644 --- a/packages/core/src/store/sync/__tests__/sealer.test.ts +++ b/packages/core/src/store/sync/__tests__/sealer.test.ts @@ -61,7 +61,7 @@ beforeEach(() => { vi.stubEnv('CLEO_ROOT', undefined); vi.stubEnv('CLEO_DIR', undefined); // Written against row uids off; on by default since T13305 (C2). The - // capture + fill-on interplay is tracked separately (see the C2 PR). + // capture + fill-on interplay (K captures alongside I/U/D) is T13311. vi.stubEnv('CLEO_ROW_UID_FILL', '0'); dbPath = join(dir, 'project', '.cleo', 'cleo.db'); }); diff --git a/packages/core/src/store/sync/__tests__/trigger-suspend.test.ts b/packages/core/src/store/sync/__tests__/trigger-suspend.test.ts index 438cbbec8d..d4454bef9e 100644 --- a/packages/core/src/store/sync/__tests__/trigger-suspend.test.ts +++ b/packages/core/src/store/sync/__tests__/trigger-suspend.test.ts @@ -279,17 +279,21 @@ describe('the open pass', () => { ).sql.replace(/CREATE TABLE [`"]?tasks_tasks[`"]?/, 'CREATE TABLE `__new_tasks_tasks`'); // A rebuild must first drop every trigger whose text references the // table (ALTER … RENAME re-validates them all, §2.3a rule 5); dropping - // the table drops its own triggers. + // the table drops its own triggers. That includes this connection's TEMP + // row-uid triggers (T13305: on by default), which the next open re-arms. const referencing = ( db .prepare( - "SELECT name FROM sqlite_master WHERE type = 'trigger' AND sql LIKE '%tasks_tasks%'", + `SELECT 'main' AS schema, name FROM sqlite_master WHERE type = 'trigger' AND sql LIKE '%tasks_tasks%' + UNION ALL + SELECT 'temp', name FROM sqlite_temp_master WHERE type = 'trigger' AND sql LIKE '%tasks_tasks%'`, ) - .all() as Array<{ name: string }> - ).map((r) => r.name); + .all() as Array<{ schema: string; name: string }> + ).map((r) => `${r.schema}."${r.name}"`); + expect(referencing.some((t) => t.startsWith('temp.'))).toBe(true); db.exec('PRAGMA foreign_keys = OFF'); db.exec(`BEGIN; - ${referencing.map((t) => `DROP TRIGGER "${t}";`).join('\n')} + ${referencing.map((t) => `DROP TRIGGER ${t};`).join('\n')} ${create}; INSERT INTO __new_tasks_tasks SELECT * FROM tasks_tasks; DROP TABLE tasks_tasks; @@ -322,6 +326,9 @@ describe('ownership', () => { }); it('the T12341 §13 rollback, after capture off and dropSyncMachinery (rule 10), leaves cleo_trigger_suspend and writes working', async () => { + // A rollback runs with the fill off (the kill switch), so no connection + // holds TEMP uid triggers that read the dropped columns (T13305). + vi.stubEnv('CLEO_ROW_UID_FILL', '0'); const db = await openStore(); setCaptureEnabled(db, 'project', true, { schemaRoot: SYNC_SCHEMA }); // Rule 10, step 0: capture off; then remove the journal.