From 678c0085e0ccac381e0f4ac37b0358b96036a79c Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Fri, 9 Oct 2026 22:35:24 -0700 Subject: [PATCH 1/5] fix(sync): only canonical store opens run the sync open pass (T13336) openDualScopeDbAtPath takes a sync mode (journal spec 1.5). A canonical, non-dedicated open is live: the capture open pass runs and, once a sync flag is on, the replica bind (syncOpenPass). Every dedicated handle and every other path is off: no capture trigger installed, verified or dropped, no bind or rebind. Exodus opens and the real-store Gate B opener pass off explicitly; backup, snapshot, bundle staging and credential transfer open raw files and never reach the open pass. A test pins each path. Co-Authored-By: Claude Opus 5.5 --- .changeset/t13336-sync-mode-off.md | 19 ++ .../store/__tests__/store-sync-mode.test.ts | 190 ++++++++++++++++++ packages/core/src/store/dual-scope-db.ts | 90 ++++++++- packages/core/src/store/exodus/migrate.ts | 2 + packages/core/src/store/exodus/on-open.ts | 20 +- packages/core/src/store/exodus/reconcile.ts | 7 +- packages/core/src/store/sync/replica.ts | 2 +- scripts/row-identity-real-store-gate-b.mjs | 3 +- 8 files changed, 322 insertions(+), 11 deletions(-) create mode 100644 .changeset/t13336-sync-mode-off.md create mode 100644 packages/core/src/store/__tests__/store-sync-mode.test.ts diff --git a/.changeset/t13336-sync-mode-off.md b/.changeset/t13336-sync-mode-off.md new file mode 100644 index 0000000000..a919cfc98f --- /dev/null +++ b/.changeset/t13336-sync-mode-off.md @@ -0,0 +1,19 @@ +--- +id: t13336-sync-mode-off +tasks: [T13336, T12753] +kind: fix +summary: "Sync: only canonical store opens run the sync open pass; copies, backups and exodus handles stay off" +--- + +Every chokepoint open used to run the capture open pass, and nothing ran the replica bind (journal spec §1.5, +T13336). Now each open has a sync mode: + +- **`live`:** a canonical, non-dedicated open (`/.cleo/cleo.db`, or `/cleo.db` for the global store). + Capture triggers match `sync.capture`, and once any `sync.*` flag is on the store is bound to its replica. A copy at + another canonical path rebinds. +- **`off`:** every dedicated handle (exodus migrate, on-open rollback and seal, reconcile scratch) and every other + path, plus the real-store Gate B opener. No capture trigger is installed, verified or dropped, and the store is + never bound or rebound. + +Backups, snapshots, bundle staging and credential-transfer handles open raw files, never through the chokepoint, so +no open pass runs on them. A test pins every one of these paths. diff --git a/packages/core/src/store/__tests__/store-sync-mode.test.ts b/packages/core/src/store/__tests__/store-sync-mode.test.ts new file mode 100644 index 0000000000..d6bc133a8e --- /dev/null +++ b/packages/core/src/store/__tests__/store-sync-mode.test.ts @@ -0,0 +1,190 @@ +/** + * Sync mode of a chokepoint open (journal spec §1.5 "Opens that skip the sync + * open pass", T13336): only a canonical, non-dedicated open runs the capture + * open pass and the replica bind. Backups, snapshots, Gate B copies, exodus + * handles, bundle staging and credential-transfer handles never install, + * verify or drop capture triggers, and never rebind. + * + * @task T13336 + */ + +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import type { DatabaseSync } from 'node:sqlite'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { _resetDeviceIdCacheForTests } from '../../llm/stable-device-id.js'; +import { + _resetDualScopeDbCache, + getDualScopeNativeDb, + openDualScopeDbAtPath, + resolveStoreSyncMode, +} from '../dual-scope-db.js'; +import { dropCaptureTriggers, setCaptureEnabled } from '../sync/capture.js'; +import { activeReplica } from '../sync/replica.js'; +import { classifyStoreTriggers } from '../sync/trigger-classes.js'; + +const SYNC_SCHEMA = resolve(import.meta.dirname, '../../../migrations/sync-journal'); +const REPO = resolve(import.meta.dirname, '../../../../..'); + +let dir: string; +let home: string; +let dbPath: string; + +async function open(path: string, options?: Parameters[3]) { + return getDualScopeNativeDb(await openDualScopeDbAtPath('project', path, undefined, options)); +} + +function captureTriggerCount(db: DatabaseSync): number { + return classifyStoreTriggers(db, 'project').classified.filter((t) => t.class === 'capture') + .length; +} + +/** A canonical store with capture on, bound to a replica by a live open. */ +async function boundCaptureStore(): Promise<{ db: DatabaseSync; replicaId: string }> { + const first = await open(dbPath); + setCaptureEnabled(first, 'project', true, { schemaRoot: SYNC_SCHEMA }); + _resetDualScopeDbCache(); + const db = await open(dbPath); + const replicaId = activeReplica(db, 'project')?.replicaId; + if (!replicaId) throw new Error('a live open with capture on must bind the store'); + return { db, replicaId }; +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'cleo-sync-mode-')); + home = join(dir, 'cleo'); + mkdirSync(join(dir, 'project', '.cleo'), { recursive: true }); + mkdirSync(home, { recursive: true }); + vi.stubEnv('CLEO_HOME', home); + vi.stubEnv('CLEO_ROOT', undefined); + vi.stubEnv('CLEO_DIR', undefined); + _resetDeviceIdCacheForTests(); + dbPath = join(dir, 'project', '.cleo', 'cleo.db'); +}); + +afterEach(() => { + _resetDualScopeDbCache(); + _resetDeviceIdCacheForTests(); + vi.unstubAllEnvs(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('resolveStoreSyncMode', () => { + it('is live only for a canonical, non-dedicated store path', () => { + expect(resolveStoreSyncMode('project', '/p/.cleo/cleo.db')).toBe('live'); + expect(resolveStoreSyncMode('global', join(home, 'cleo.db'))).toBe('live'); + expect(resolveStoreSyncMode('project', '/p/.cleo/backups/sqlite/cleo-1.db')).toBe('off'); + expect(resolveStoreSyncMode('project', '/tmp/stage/cleo.db')).toBe('off'); + expect(resolveStoreSyncMode('global', join(dir, 'elsewhere', 'cleo.db'))).toBe('off'); + expect(resolveStoreSyncMode('project', '/p/.cleo/cleo.db', { dedicated: true })).toBe('off'); + expect(resolveStoreSyncMode('project', '/p/.cleo/cleo.db', { syncMode: 'off' })).toBe('off'); + expect(resolveStoreSyncMode('project', '/tmp/x.db', { syncMode: 'live' })).toBe('live'); + }); +}); + +describe('a canonical open runs the sync open pass in live mode', () => { + it('binds the store once a sync flag is on, and keeps the same replica on reopen', async () => { + const { replicaId } = await boundCaptureStore(); + _resetDualScopeDbCache(); + expect(activeReplica(await open(dbPath), 'project')?.replicaId).toBe(replicaId); + }); + + it('rebinds a copy that lands at another canonical path', async () => { + const { db, replicaId } = await boundCaptureStore(); + db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); + const copy = join(dir, 'other', '.cleo', 'cleo.db'); + mkdirSync(join(dir, 'other', '.cleo'), { recursive: true }); + copyFileSync(dbPath, copy); + const row = activeReplica(await open(copy), 'project'); + expect(row?.replicaId).toBeDefined(); + expect(row?.replicaId).not.toBe(replicaId); + }); + + it('re-installs capture triggers a canonical reopen finds missing', async () => { + const { db } = await boundCaptureStore(); + dropCaptureTriggers(db); + _resetDualScopeDbCache(); + expect(captureTriggerCount(await open(dbPath))).toBeGreaterThan(0); + }); +}); + +describe('a sync-off open never touches capture triggers or the replica', () => { + it('a copy at a non-canonical path (backup, snapshot, scratch) is never rebound', async () => { + const { db, replicaId } = await boundCaptureStore(); + db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); + const scratch = join(dir, 'scratch', 'cleo.db'); + mkdirSync(join(dir, 'scratch'), { recursive: true }); + copyFileSync(dbPath, scratch); + expect(activeReplica(await open(scratch), 'project')?.replicaId).toBe(replicaId); + }); + + it('a dedicated open (exodus, reconcile scratch) neither installs nor drops triggers', async () => { + const { db } = await boundCaptureStore(); + dropCaptureTriggers(db); + const dedicated = await openDualScopeDbAtPath('project', dbPath, undefined, { + dedicated: true, + }); + try { + expect(captureTriggerCount(getDualScopeNativeDb(dedicated))).toBe(0); + } finally { + dedicated.close(); + } + }); + + it('an explicit off open of a canonical path never rebinds nor re-installs', async () => { + const { db, replicaId } = await boundCaptureStore(); + dropCaptureTriggers(db); + db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); + const copy = join(dir, 'gate-b', '.cleo', 'cleo.db'); + mkdirSync(join(dir, 'gate-b', '.cleo'), { recursive: true }); + copyFileSync(dbPath, copy); + const off = await open(copy, { syncMode: 'off' }); + expect(activeReplica(off, 'project')?.replicaId).toBe(replicaId); + expect(captureTriggerCount(off)).toBe(0); + }); +}); + +/** + * Every non-canonical open path named by §1.5 is pinned to sync mode off: + * chokepoint opens pass `syncMode: 'off'`, and the raw-handle paths never + * open through the chokepoint (so no open pass can run on them). + */ +describe('non-canonical open paths are pinned to sync mode off', () => { + const read = (rel: string) => readFileSync(join(REPO, rel), 'utf8'); + const CHOKEPOINT_OPEN = /openDualScopeDb(?:AtPath)?\(/g; + /** Whether the match at `at` sits on a comment line (TSDoc or `//`). */ + const isComment = (src: string, at: number): boolean => { + const line = src.slice(src.lastIndexOf('\n', at) + 1, at).trimStart(); + return line.startsWith('*') || line.startsWith('//') || line.startsWith('/*'); + }; + + it.each([ + 'packages/core/src/store/exodus/migrate.ts', + 'packages/core/src/store/exodus/on-open.ts', + 'packages/core/src/store/exodus/reconcile.ts', + 'scripts/row-identity-real-store-gate-b.mjs', + ])('%s passes syncMode off on every chokepoint open', (rel) => { + const src = read(rel); + const opens = [...src.matchAll(CHOKEPOINT_OPEN)] + .map((m) => m.index ?? 0) + .filter((at) => !isComment(src, at)); + expect(opens.length).toBeGreaterThan(0); + for (const at of opens) { + const call = src.slice(at, src.indexOf(');', at)); + expect(call, `${rel}@${at}`).toContain("syncMode: 'off'"); + } + }); + + it.each([ + 'packages/core/src/store/backup-pack.ts', + 'packages/core/src/store/credential-transfer.ts', + 'packages/core/src/store/vault-manifest.ts', + 'packages/core/src/snapshot/index.ts', + 'scripts/fingerprint-store.mjs', + ])('%s never opens a store through the chokepoint', (rel) => { + const src = read(rel); + const opens = [...src.matchAll(CHOKEPOINT_OPEN)].filter((m) => !isComment(src, m.index ?? 0)); + expect(opens).toEqual([]); + }); +}); diff --git a/packages/core/src/store/dual-scope-db.ts b/packages/core/src/store/dual-scope-db.ts index 32597d8c4f..c3f7214acf 100644 --- a/packages/core/src/store/dual-scope-db.ts +++ b/packages/core/src/store/dual-scope-db.ts @@ -52,7 +52,7 @@ import { existsSync, mkdirSync } from 'node:fs'; import { createRequire } from 'node:module'; -import { dirname, join, resolve } from 'node:path'; +import { basename, dirname, join, resolve } from 'node:path'; import type { DatabaseSync } from 'node:sqlite'; import { ExitCode } from '@cleocode/contracts/exit-codes.js'; import type { OperationExecutionContext } from '@cleocode/contracts/jobs'; @@ -93,6 +93,7 @@ import { import { rowUidFillEnabled } from './row-identity-flag.js'; import { applyPerfPragmas } from './sqlite-pragmas.js'; import { syncCaptureOpenPass } from './sync/capture.js'; +import { anySyncFlagOn } from './sync/flags.js'; import { prepareRowIdentityUnderCapture } from './sync/identity-fill.js'; import { syncMigrationHooks } from './sync/migration-hooks.js'; import { ensureTriggerSuspendTable, verifyOwnedTriggers } from './sync/trigger-classes.js'; @@ -209,6 +210,46 @@ export interface OpenDualScopeAtPathOptions { * @default false */ readonly dedicated?: boolean; + /** + * How the physical open treats sync (journal spec §1.5, T13336). `live` + * runs the capture open pass (triggers match `sync.capture`) and the + * replica bind (`syncOpenPass`). `off` runs neither: no capture + * trigger is installed, verified or dropped, and the store is never bound + * or rebound, so nothing it writes is sealed as a new replica's. + * + * Defaults to `off` for a dedicated handle and for any path that is not a + * canonical store (`/.cleo/cleo.db`, or `/cleo.db` for the + * global scope), and to `live` otherwise. A cached handle keeps the mode of + * the open that created it. + */ + readonly syncMode?: StoreSyncMode; +} + +/** How a chokepoint open treats sync: see {@link OpenDualScopeAtPathOptions.syncMode}. */ +export type StoreSyncMode = 'live' | 'off'; + +/** + * Whether `normalizedPath` is a canonical store file: the global store at + * `/cleo.db`, or a project store at `/.cleo/cleo.db`. + * Backups, snapshots, scratch copies and staged bundles live elsewhere. + */ +function isCanonicalStorePath(scope: DualScope, normalizedPath: string): boolean { + if (scope === 'global') return normalizedPath === resolve(resolveDualScopeDbPath('global')); + return basename(normalizedPath) === 'cleo.db' && basename(dirname(normalizedPath)) === '.cleo'; +} + +/** + * The sync mode of a physical open (T13336): the caller's, else `off` for a + * dedicated handle or a non-canonical path, else `live`. + */ +export function resolveStoreSyncMode( + scope: DualScope, + dbPath: string, + options?: Pick, +): StoreSyncMode { + if (options?.syncMode) return options.syncMode; + if (options?.dedicated === true) return 'off'; + return isCanonicalStorePath(scope, resolve(dbPath)) ? 'live' : 'off'; } // The refusal error lives with the abort records (exodus/abort-events.ts), a @@ -794,6 +835,7 @@ async function migrateScopeSchema( db: NodeSQLiteDatabase, migrationsFolder: string, log: ReturnType, + sync: { readonly mode: StoreSyncMode; readonly dbPath: string }, execution?: OperationExecutionContext, ): Promise { execution?.assertActive(); @@ -843,6 +885,9 @@ async function migrateScopeSchema( log.warn({ scope, findings }, 'owned triggers repaired from their owned DDL (T12819)'); } } + // §1.5 (T13336): a non-canonical open (dedicated, backup, scratch copy) + // never installs, verifies or drops capture triggers, and never binds. + if (sync.mode === 'off') return; // T12343 (S2): capture triggers match the persisted sync.capture flag; // with the flag off and no trigger present this only reads. const capture = syncCaptureOpenPass(nativeDb, scope); @@ -857,6 +902,23 @@ async function migrateScopeSchema( 'capture triggers dropped: sync.capture is off (T12343)', ); } + // §1.5 (T13336): the replica bind of a canonical open. With every sync.* + // flag off it reads only the flags, so the module loads only when one is on. + if (anySyncFlagOn(nativeDb)) { + const { syncOpenPass } = await import('./sync/replica.js'); + const bound = syncOpenPass(nativeDb, { dbPath: sync.dbPath, scope, mode: 'live' }); + if (bound.status === 'rebound') { + log.warn( + { + scope, + replicaId: bound.replicaId, + previous: bound.previousReplicaId, + reasons: bound.reasons, + }, + 'store rebound to a new replica at open (T13336)', + ); + } + } } /** @@ -897,6 +959,7 @@ async function prepareFillWriters(nativeDb: DatabaseSync, scope: DualScope): Pro * @param scope - The consolidated schema scope. * @param dbPath - Absolute path to the consolidated `cleo.db` file. * @param log - The module logger. + * @param syncMode - Always `off` unless the caller asked for `live` (T13336). * @returns A typed {@link DualScopeDbHandle} backed by a dedicated connection. * * @task T11782 (FIX D — rollback connection isolation) @@ -905,6 +968,7 @@ async function openDedicatedDualScopeDb( scope: DualScope, dbPath: string, log: ReturnType, + syncMode: StoreSyncMode, execution?: OperationExecutionContext, ): Promise { execution?.assertActive(); @@ -949,7 +1013,15 @@ async function openDedicatedDualScopeDb( nativeDb, async (): Promise => { execution?.assertActive(); - await migrateScopeSchema(scope, nativeDb, db, migrationsFolder, log, execution); + await migrateScopeSchema( + scope, + nativeDb, + db, + migrationsFolder, + log, + { mode: syncMode, dbPath }, + execution, + ); // T12341: fill row uids (opt-in). No per-connection uid triggers: // dedicated handles run the exodus copy, whose effect inspection @@ -1138,9 +1210,11 @@ export async function openDualScopeDbAtPath( } const log = getLogger('dual-scope-db'); + // §1.5 (T13336): only a canonical, non-dedicated open runs the sync open pass. + const syncMode = resolveStoreSyncMode(scope, normalizedPath, options); if (dedicated) { - return openDedicatedDualScopeDb(scope, normalizedPath, log, execution); + return openDedicatedDualScopeDb(scope, normalizedPath, log, syncMode, execution); } // Create a placeholder entry so concurrent callers wait for the same init. @@ -1264,7 +1338,15 @@ export async function openDualScopeDbAtPath( execution?.assertActive(); // The schema pass also heals the identity schema (T12878), on every // open, flag or not, before capture is installed. - await migrateScopeSchema(scope, nativeDb, db, migrationsFolder, log, execution); + await migrateScopeSchema( + scope, + nativeDb, + db, + migrationsFolder, + log, + { mode: syncMode, dbPath: normalizedPath }, + execution, + ); // T12341 (opt-in, CLEO_ROW_UID_FILL=1): fill every NULL row uid // deterministically, inside this lease so two processes never fill at diff --git a/packages/core/src/store/exodus/migrate.ts b/packages/core/src/store/exodus/migrate.ts index aee584f99c..a62f49b256 100644 --- a/packages/core/src/store/exodus/migrate.ts +++ b/packages/core/src/store/exodus/migrate.ts @@ -1168,12 +1168,14 @@ export async function runExodusMigrate( onProgress?.('Opening DEDICATED project-scope cleo.db connection (running migrations)…'); projectHandle = await openDualScopeDbAtPath('project', projectDbPath, undefined, { dedicated: true, + syncMode: 'off', }); if (!projectOnly) { onProgress?.('Opening DEDICATED global-scope cleo.db connection (running migrations)…'); globalHandle = await openDualScopeDbAtPath('global', globalDbPath, undefined, { dedicated: true, + syncMode: 'off', }); } diff --git a/packages/core/src/store/exodus/on-open.ts b/packages/core/src/store/exodus/on-open.ts index 294e64e1fc..086bdd21f1 100644 --- a/packages/core/src/store/exodus/on-open.ts +++ b/packages/core/src/store/exodus/on-open.ts @@ -234,8 +234,14 @@ async function rollbackBothScopes(plan: ExodusPlan): Promise { const { getDualScopeNativeDb, openDualScopeDbAtPath } = await import('../dual-scope-db.js'); const { ensureTasksDomainTables, seedTasksMeta } = await import('../sqlite.js'); - const handle = await openDualScopeDbAtPath('project', path, undefined, { dedicated: true }); + const handle = await openDualScopeDbAtPath('project', path, undefined, { + dedicated: true, + syncMode: 'off', + }); try { const native = getDualScopeNativeDb(handle); ensureTasksDomainTables(native, path); @@ -503,6 +506,7 @@ async function recordCarriedBareTables( const { getDualScopeNativeDb, openDualScopeDbAtPath } = await import('../dual-scope-db.js'); const handle = await openDualScopeDbAtPath('project', liveStorePath, undefined, { dedicated: true, + syncMode: 'off', }); try { recordBareAccounts(getDualScopeNativeDb(handle), accounts, run); @@ -516,6 +520,7 @@ async function revertReconcile(liveStorePath: string, stagingDir: string): Promi const { getDualScopeNativeDb, openDualScopeDbAtPath } = await import('../dual-scope-db.js'); const handle = await openDualScopeDbAtPath('project', liveStorePath, undefined, { dedicated: true, + syncMode: 'off', }); try { return rollbackExodusReceipts(getDualScopeNativeDb(handle), stagingDir); diff --git a/packages/core/src/store/sync/replica.ts b/packages/core/src/store/sync/replica.ts index f9acfc101f..21b0b38653 100644 --- a/packages/core/src/store/sync/replica.ts +++ b/packages/core/src/store/sync/replica.ts @@ -436,7 +436,7 @@ function register( * or a foreign store, and heals the clock. Then it brings the device registry * up to date (re-registering a store the registry lost, recording a rename). * - * Not yet called by `openDualScopeDb`: the capture slice (S2) wires it in. + * Called by every canonical `live` chokepoint open (`openDualScopeDbAtPath`, T13336). */ export function syncOpenPass(db: DatabaseSync, opts: SyncOpenOptions): SyncOpenResult { if (opts.mode === 'off') return { status: 'off' }; diff --git a/scripts/row-identity-real-store-gate-b.mjs b/scripts/row-identity-real-store-gate-b.mjs index fd2040af59..0e911312bd 100644 --- a/scripts/row-identity-real-store-gate-b.mjs +++ b/scripts/row-identity-real-store-gate-b.mjs @@ -145,7 +145,8 @@ const [coreDist, projectDir, out] = process.argv.slice(1); const { openDualScopeDb } = await import(new URL('store/dual-scope-db.js', coreDist).href); const { getDb } = await import(new URL('store/sqlite.js', coreDist).href); const started = performance.now(); -const handle = await openDualScopeDb('project', projectDir); +// Gate B copies open with sync off (§1.5, T13336): never bound, no capture pass. +const handle = await openDualScopeDb('project', projectDir, { syncMode: 'off' }); await getDb(projectDir); const openMs = Math.round(performance.now() - started); handle.db.$client.exec("VACUUM INTO '" + out.replaceAll("'", "''") + "'"); From b04860a3f18789f2803c94533ddf2d4eae7d61b7 Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Fri, 9 Oct 2026 22:39:03 -0700 Subject: [PATCH 2/5] test(sync): keep the live bind's replica registry in the test's state dir (T13336) Co-Authored-By: Claude Opus 5.5 --- packages/core/src/store/__tests__/store-sync-mode.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/core/src/store/__tests__/store-sync-mode.test.ts b/packages/core/src/store/__tests__/store-sync-mode.test.ts index d6bc133a8e..d23ee9718e 100644 --- a/packages/core/src/store/__tests__/store-sync-mode.test.ts +++ b/packages/core/src/store/__tests__/store-sync-mode.test.ts @@ -57,6 +57,9 @@ beforeEach(() => { mkdirSync(join(dir, 'project', '.cleo'), { recursive: true }); mkdirSync(home, { recursive: true }); vi.stubEnv('CLEO_HOME', home); + // The live bind writes the device replica registry under the state dir: + // CLEO_HOME on macOS and Windows, XDG_STATE_HOME on Linux. + vi.stubEnv('XDG_STATE_HOME', join(dir, 'state')); vi.stubEnv('CLEO_ROOT', undefined); vi.stubEnv('CLEO_DIR', undefined); _resetDeviceIdCacheForTests(); From 6124c80b4c482da15d13103b91e0545e2ae2f8b1 Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Fri, 9 Oct 2026 23:30:01 -0700 Subject: [PATCH 3/5] fix(sync): capture brackets restore only the triggers they dropped (T13336) With row uids on by default (#1964), every open runs a structural bracket for the uid fill. The bracket reinstalled capture triggers whenever sync.capture was set, so a sync-off open (dedicated, Gate B copy) gained triggers it never had. The structural bracket and the migration-runner hooks now regenerate only what they dropped. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VjjTKbQXyCFdkLBSeuyKLt --- .../store/__tests__/store-sync-mode.test.ts | 24 ++++++++++++++++++- packages/core/src/store/sync/capture.ts | 7 ++++-- packages/core/src/store/sync/structural.ts | 4 +++- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/core/src/store/__tests__/store-sync-mode.test.ts b/packages/core/src/store/__tests__/store-sync-mode.test.ts index d23ee9718e..c9aa30ad05 100644 --- a/packages/core/src/store/__tests__/store-sync-mode.test.ts +++ b/packages/core/src/store/__tests__/store-sync-mode.test.ts @@ -20,7 +20,7 @@ import { openDualScopeDbAtPath, resolveStoreSyncMode, } from '../dual-scope-db.js'; -import { dropCaptureTriggers, setCaptureEnabled } from '../sync/capture.js'; +import { captureBracketHooks, dropCaptureTriggers, setCaptureEnabled } from '../sync/capture.js'; import { activeReplica } from '../sync/replica.js'; import { classifyStoreTriggers } from '../sync/trigger-classes.js'; @@ -148,6 +148,28 @@ describe('a sync-off open never touches capture triggers or the replica', () => }); }); +describe('capture brackets restore only what they dropped', () => { + it('a migration bracket on a store with capture on but no triggers installs none', async () => { + const { db } = await boundCaptureStore(); + dropCaptureTriggers(db); + const hooks = captureBracketHooks(db, 'project'); + hooks.suspendCapture?.(db); + hooks.reinstallCapture?.(db); + expect(captureTriggerCount(db)).toBe(0); + }); + + it('a migration bracket regenerates the triggers it dropped', async () => { + const { db } = await boundCaptureStore(); + const before = captureTriggerCount(db); + expect(before).toBeGreaterThan(0); + const hooks = captureBracketHooks(db, 'project'); + hooks.suspendCapture?.(db); + expect(captureTriggerCount(db)).toBe(0); + hooks.reinstallCapture?.(db); + expect(captureTriggerCount(db)).toBe(before); + }); +}); + /** * Every non-canonical open path named by §1.5 is pinned to sync mode off: * chokepoint opens pass `syncMode: 'off'`, and the raw-handle paths never diff --git a/packages/core/src/store/sync/capture.ts b/packages/core/src/store/sync/capture.ts index d42e59a518..c2c1f68304 100644 --- a/packages/core/src/store/sync/capture.ts +++ b/packages/core/src/store/sync/capture.ts @@ -794,12 +794,15 @@ export function captureBracketHooks( scope: TableScope, ): { suspendCapture?(db: DatabaseSync): void; reinstallCapture?(db: DatabaseSync): void } { if (!readSyncFlags(db)['sync.capture']) return {}; + // Regenerate only what the bracket dropped: a sync-off open (T13336) whose + // store has no capture triggers must not gain them from a migration. + const dropped = new WeakSet(); return { suspendCapture: (d) => { - dropCaptureTriggers(d); + if (dropCaptureTriggers(d).length > 0) dropped.add(d); }, reinstallCapture: (d) => { - if (hasTable(d, '_sync_capture')) installCaptureTriggers(d, scope); + if (dropped.delete(d) && hasTable(d, '_sync_capture')) installCaptureTriggers(d, scope); }, }; } diff --git a/packages/core/src/store/sync/structural.ts b/packages/core/src/store/sync/structural.ts index 3d26447d40..53fc87810b 100644 --- a/packages/core/src/store/sync/structural.ts +++ b/packages/core/src/store/sync/structural.ts @@ -156,7 +156,9 @@ function bracket( capture = readSyncFlags(db)['sync.capture'] && hasTable(db, '_sync_capture'); const before = rebuilds.length > 0 ? foreignKeyViolations(db, foreignKeyScope(db, rebuilds)) : null; - if (capture) dropCaptureTriggers(db); + // Restore only what was dropped: a sync-off open (T13336) whose store has + // no capture triggers must not gain them from a structural change. + if (capture) capture = dropCaptureTriggers(db).length > 0; const out = fn(); if (!db.isTransaction) { // @sync-invariant none:local-only programming-error guard on the local suspension bracket From 3fda227a72b1ebb6ec690ffa64ee61347539695a Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Sun, 11 Oct 2026 01:18:59 -0700 Subject: [PATCH 4/5] fix(store): buildFreshProjectStore opens with sync mode off; pin bare-family.ts (T13531) Main moved buildFreshProjectStore into exodus/bare-family.ts without the T13336 syncMode: 'off', and the pinning test listed only migrate, on-open and reconcile. Add the option and pin the file. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/store/__tests__/store-sync-mode.test.ts | 1 + packages/core/src/store/exodus/bare-family.ts | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/store/__tests__/store-sync-mode.test.ts b/packages/core/src/store/__tests__/store-sync-mode.test.ts index c9aa30ad05..3de4c92b83 100644 --- a/packages/core/src/store/__tests__/store-sync-mode.test.ts +++ b/packages/core/src/store/__tests__/store-sync-mode.test.ts @@ -185,6 +185,7 @@ describe('non-canonical open paths are pinned to sync mode off', () => { }; it.each([ + 'packages/core/src/store/exodus/bare-family.ts', 'packages/core/src/store/exodus/migrate.ts', 'packages/core/src/store/exodus/on-open.ts', 'packages/core/src/store/exodus/reconcile.ts', diff --git a/packages/core/src/store/exodus/bare-family.ts b/packages/core/src/store/exodus/bare-family.ts index 5f8585d701..0ae52de61e 100644 --- a/packages/core/src/store/exodus/bare-family.ts +++ b/packages/core/src/store/exodus/bare-family.ts @@ -169,7 +169,10 @@ async function materializeBareTables( async function buildFreshProjectStore(path: string): Promise { const { getDualScopeNativeDb, openDualScopeDbAtPath } = await import('../dual-scope-db.js'); const { ensureTasksDomainTables, seedTasksMeta } = await import('../sqlite.js'); - const handle = await openDualScopeDbAtPath('project', path, undefined, { dedicated: true }); + const handle = await openDualScopeDbAtPath('project', path, undefined, { + dedicated: true, + syncMode: 'off', + }); try { const native = getDualScopeNativeDb(handle); ensureTasksDomainTables(native, path); From 80293d2c9633bb3d0fd28cdca202c9f48f996e6e Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Sun, 11 Oct 2026 08:38:24 -0700 Subject: [PATCH 5/5] ci: the code filter covers the Gate B opener script store-sync-mode.test reads (T13336) scripts/__tests__/ci-code-filter-script-deps (T13175) requires every script a package test reads to be in the ci.yml and macos-main.yml code filters; store-sync-mode.test pins scripts/row-identity-real-store-gate-b.mjs. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 1 + .github/workflows/macos-main.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4a2201aa7..2f802f2833 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -212,6 +212,7 @@ jobs: - 'scripts/lint-row-identity-coverage.mjs' - 'scripts/lint-sync-write-invariants.mjs' - 'scripts/migrate-nested-nexus.mjs' + - 'scripts/row-identity-real-store-gate-b.mjs' - 'scripts/sweep-manual-doc-writes.mjs' # gh#1403: `scripts/**` is deliberately NOT in `code` above. The # scripts tests are a real vitest project (`scripts/vitest.config.ts`, diff --git a/.github/workflows/macos-main.yml b/.github/workflows/macos-main.yml index 4404539049..8869b0558f 100644 --- a/.github/workflows/macos-main.yml +++ b/.github/workflows/macos-main.yml @@ -55,6 +55,7 @@ on: - 'scripts/lint-row-identity-coverage.mjs' - 'scripts/lint-sync-write-invariants.mjs' - 'scripts/migrate-nested-nexus.mjs' + - 'scripts/row-identity-real-store-gate-b.mjs' - 'scripts/sweep-manual-doc-writes.mjs' - 'crates/**' - '.github/workflows/macos-main.yml'