Evidence
src/_core/db/scope.ts:14-17 treats a NULL scope as global:
export function scopeVisible(rowScope, scopes) {
if (!scopes || scopes.length === 0) return true;
return scopes.includes(rowScope ?? GLOBAL_SCOPE);
}
scope.ts:20-23 does not:
export function scopeInClause(col, scopes) {
if (!scopes || scopes.length === 0) return null;
return { sql: `${col} IN (${scopes.map(() => "?").join(", ")})`, params: [...scopes] };
}
col IN (...) is never true for NULL. widenScope (scope.ts:37) also guards scope IS NOT NULL, so a NULL row is neither widened nor listed. Callers of scopeInClause: src/episodic/search.ts:64,107, src/semantic/inspect.ts:80, src/semantic/commitments.ts:604.
Mechanism
Every scoped table except memories declares scope TEXT DEFAULT 'global' without NOT NULL (schema.ts:620,655,790). A row that ends up with scope = NULL is visible to scopeVisible-based readers (the semantic layer, forgetMemory's assertScope) but invisible to scopeInClause-based readers (episodic recall, engram memories list, commitments) for any read_scopes, including ones that contain global.
Severity note
Verified there is no in-tree producer of NULL scope today: every INSERT coalesces to 'global' (store.ts, entity.ts, relationship.ts, commitments.ts), and the ALTER TABLE ... DEFAULT 'global' migrations back-fill existing rows. It becomes reachable through an external write, a hand edit, or a future insert that binds undefined (better-sqlite3 binds it as NULL). Filing as a latent consistency bug, P3.
Suggested fix
One line in scopeInClause: COALESCE(${col}, 'global') IN (...), or (${col} IN (...) OR ${col} IS NULL) when the list contains global, so both helpers agree. Alternatively add NOT NULL to the four columns in a migration.
Related: #25.
Evidence
src/_core/db/scope.ts:14-17treats a NULL scope asglobal:scope.ts:20-23does not:col IN (...)is never true for NULL.widenScope(scope.ts:37) also guardsscope IS NOT NULL, so a NULL row is neither widened nor listed. Callers ofscopeInClause:src/episodic/search.ts:64,107,src/semantic/inspect.ts:80,src/semantic/commitments.ts:604.Mechanism
Every scoped table except
memoriesdeclaresscope TEXT DEFAULT 'global'withoutNOT NULL(schema.ts:620,655,790). A row that ends up withscope = NULLis visible toscopeVisible-based readers (the semantic layer,forgetMemory'sassertScope) but invisible toscopeInClause-based readers (episodic recall,engram memories list, commitments) for anyread_scopes, including ones that containglobal.Severity note
Verified there is no in-tree producer of NULL scope today: every
INSERTcoalesces to'global'(store.ts,entity.ts,relationship.ts,commitments.ts), and theALTER TABLE ... DEFAULT 'global'migrations back-fill existing rows. It becomes reachable through an external write, a hand edit, or a future insert that bindsundefined(better-sqlite3 binds it as NULL). Filing as a latent consistency bug, P3.Suggested fix
One line in
scopeInClause:COALESCE(${col}, 'global') IN (...), or(${col} IN (...) OR ${col} IS NULL)when the list containsglobal, so both helpers agree. Alternatively addNOT NULLto the four columns in a migration.Related: #25.