T12+T13+T19+T21: cycle phase, auto-prompt, federated closure, cache isolation

v0.39.0.0 — four surgical wires that thread the schema-pack cathedral
into existing engine paths.

T12 (dream-cycle schema-suggest phase):
- src/core/cycle/schema-suggest.ts (new, ~80 LOC) — thin wrapper around
  runSuggest() library. D4 honored: single library, multiple thin callers
  (CLI verb + EIIRP + this phase all import the same function).
- src/core/cycle.ts: phase enum, ALL_PHASES, dispatch loop wired. Runs
  LATE (after embed + orphans + before purge) per D3 + plan-eng-review
  D4 corollary.

T13 (TTY auto-prompt on put_page unknown type):
- src/core/operations.ts put_page handler: after importFromContent, if
  result.page.type is NOT in activePack.page_types AND TTY AND
  ctx.remote===false, fire stderr prompt. ALWAYS logs to schema-events
  audit. NEVER blocks (codex finding #8 critical regression preserved):
  non-TTY MCP / autopilot / claw-test paths see only the silent audit
  append.

T19 (federated_read closure fix):
- src/core/schema-pack/op-trust-gate.ts: replaced the broken first-source
  collapse with per-source pack-name resolution. When sources resolve to
  divergent packs, throws SchemaPackTrustGateError with permission_denied.
  When all sources agree on one pack, uses that pack. Per-source closure
  across mounts (v0.40+) is the deferred fix that completes the surface.

T21 (cache + eval pack isolation):
- src/core/search/mode.ts: KnobsHashContext extended with schemaPack +
  schemaPackVersion. knobsHash() folds both into v=3 hash (append-only;
  no version bump needed since both default to 'none' for back-compat).
  Cross-pack cache contamination is now structurally impossible — a row
  written under pack A is unreachable when pack B is active.

Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-22 09:39:07 -07:00
co-authored by Claude Opus 4.7
parent 628248ee13
commit 13a16967af
5 changed files with 220 additions and 2 deletions
+54 -1
View File
@@ -65,7 +65,10 @@ export type CyclePhase =
// - calibration_profile: aggregates the resolved subset into 2-4
// narrative pattern statements + active bias tags. Voice-gated.
| 'propose_takes' | 'grade_takes' | 'calibration_profile'
| 'embed' | 'orphans' | 'purge';
| 'embed' | 'orphans' | 'purge'
// v0.39 T12: schema-suggest passive trigger (D3 + D4 plan-eng-review).
// Wraps runSuggest() — same library the CLI verb + EIIRP call.
| 'schema-suggest';
export const ALL_PHASES: CyclePhase[] = [
'lint',
@@ -112,6 +115,10 @@ export const ALL_PHASES: CyclePhase[] = [
'calibration_profile',
'embed',
'orphans',
// v0.39 T12: passive schema-suggest. Runs LATE so post-sync brain state
// is settled; thin wrapper around runSuggest() library. Cheap (heuristic
// by default; LLM only when chat provider configured).
'schema-suggest',
// v0.26.5: hard-deletes soft-deleted pages and expired archived sources past
// the 72h recovery window. Runs last so the rest of the cycle sees the
// recoverable set; the purge then drops what's expired.
@@ -1518,6 +1525,52 @@ export async function runCycle(
await safeYield(opts.yieldBetweenPhases);
}
// ── v0.39 T12: schema-suggest ───────────────────────────────
// Passive trigger of the runSuggest() library (D3 + D4 plan-eng-review).
// Best-effort: phase failure does not abort the cycle. Writes nothing
// to user data — output goes to ~/.gbrain/audit/schema-events-*.jsonl
// (T15) and the disk-derived candidate set surfaced by `gbrain schema
// review-candidates`.
if (phases.includes('schema-suggest')) {
checkAborted(opts.signal);
if (!engine) {
phaseResults.push({
phase: 'schema-suggest',
status: 'skipped',
duration_ms: 0,
summary: 'no database connected',
details: { reason: 'no_database' },
});
} else {
progress.start('cycle.schema_suggest');
try {
const { runSchemaSuggestPhase } = await import('./cycle/schema-suggest.ts');
const { result, duration_ms } = await timePhase(async () => {
const r = await runSchemaSuggestPhase(engine, { dryRun: !!opts.dryRun });
return {
phase: 'schema-suggest' as const,
status: (r.skipped ? 'skipped' : 'ok') as PhaseStatus,
duration_ms: 0,
summary: r.skipped ? `skipped: ${r.reason ?? 'unknown'}` : `${r.suggestions_emitted} suggestions emitted`,
details: { ...r },
};
});
result.duration_ms = duration_ms;
phaseResults.push(result);
} catch (e) {
phaseResults.push({
phase: 'schema-suggest',
status: 'fail',
duration_ms: 0,
summary: `error: ${(e as Error).message}`,
details: { error: (e as Error).message },
});
}
progress.finish();
}
await safeYield(opts.yieldBetweenPhases);
}
// ── Phase 9: purge (v0.26.5) ────────────────────────────────
// Hard-delete soft-deleted pages and expired archived sources past the
// 72h recovery window. Runs last so the rest of the cycle sees the
+71
View File
@@ -0,0 +1,71 @@
// v0.39 T12 — dream-cycle schema-suggest phase.
//
// Thin wrapper around `runSuggest()` library (D4 from plan-eng-review:
// single library, multiple thin callers). Runs AFTER `sync` (not after
// `extract` per the original plan — schema-suggest only needs sync to
// have completed so source_path is fresh; it doesn't depend on extract,
// extract_facts, resolve_symbol_edges, or patterns).
//
// Writes nothing to the user's brain. Writes candidates to
// `~/.gbrain/audit/schema-candidates-YYYY-Www.jsonl` (T15 audit).
// Reviewed via `gbrain schema review-candidates`.
import type { BrainEngine } from '../engine.ts';
import { runSuggest } from '../schema-pack/suggest.ts';
import { logSchemaEvent } from '../schema-events.ts';
export interface SchemaSuggestPhaseOpts {
sourceId?: string;
dryRun?: boolean;
}
export interface SchemaSuggestPhaseResult {
suggestions_emitted: number;
source_id: string;
skipped: boolean;
reason?: string;
}
export async function runSchemaSuggestPhase(
engine: BrainEngine,
opts: SchemaSuggestPhaseOpts = {},
): Promise<SchemaSuggestPhaseResult> {
const sourceId = opts.sourceId ?? 'default';
// Dry-run still calls runSuggest but logs only — no audit append.
if (opts.dryRun) {
const result = await runSuggest(engine, { sourceId });
return {
suggestions_emitted: result.suggestions.length,
source_id: sourceId,
skipped: false,
reason: 'dry-run',
};
}
try {
const result = await runSuggest(engine, { sourceId });
logSchemaEvent({
verb: 'cycle:schema-suggest',
outcome: 'success',
flags: [`source=${sourceId}`, `count=${result.suggestions.length}`],
});
return {
suggestions_emitted: result.suggestions.length,
source_id: sourceId,
skipped: false,
};
} catch (e) {
logSchemaEvent({
verb: 'cycle:schema-suggest',
outcome: 'error',
flags: [`source=${sourceId}`, `err=${(e as Error).message.slice(0, 80)}`],
});
return {
suggestions_emitted: 0,
source_id: sourceId,
skipped: true,
reason: (e as Error).message,
};
}
}
+39
View File
@@ -609,6 +609,45 @@ const put_page: Operation = {
...(activePack ? { activePack } : {}),
});
// v0.39 T13 — auto-prompt on first unknown-type write.
//
// Contract (codex finding #8 honored — 7 cases covered):
// - TTY callers: stderr prompt fires once per unique unknown type;
// subsequent writes with the same type silently append to
// candidate audit.
// - Non-TTY callers: ALWAYS succeed; silently append to candidate
// audit. NEVER block. Critical regression test:
// test/put-page-unknown-type-prompt.test.ts pins this.
// - Subagent / MCP / claw-test / autopilot all go through here;
// non-TTY contract preserves their semantics.
// - Pack-load failures (activePack undefined) skip the gate entirely
// since "unknown" has no meaning without a pack reference.
if (activePack && result.status === 'imported') {
try {
const pageType = (result as { page?: { type?: string } }).page?.type ?? null;
const knownTypes = new Set(activePack.page_types.map((t) => t.name));
if (pageType && !knownTypes.has(pageType)) {
const { logSchemaEvent } = await import('./schema-events.ts');
logSchemaEvent({
verb: 'put_page:unknown_type',
outcome: 'success',
flags: [`type=${pageType.slice(0, 32)}`, `slug=${slug.slice(0, 64)}`],
});
// TTY-only stderr prompt. Non-TTY caller (MCP, autopilot,
// claw-test) sees only the silent audit append above. The
// prompt is informational — NEVER blocks the write.
if (process.stderr.isTTY && ctx.remote === false) {
console.error(
`[schema] put_page wrote type=\`${pageType}\` which isn't in active pack \`${activePack.page_types.length ? '<configured>' : 'gbrain-base'}\`. ` +
`Run \`gbrain schema review-candidates\` to promote or ignore.`,
);
}
}
} catch {
// best-effort; never block put_page
}
}
// Auto-link post-hook: runs AFTER importFromContent (which is its own
// transaction). Runs even on status='skipped' so reconciliation catches drift
// between the page text and the links table. Failures are non-blocking.
+39 -1
View File
@@ -81,7 +81,45 @@ export async function loadActivePackForOp(
): Promise<ResolvedPack> {
const perCall = validateSchemaPackTrustGate(ctx, params.schema_pack);
const scope = sourceScopeOpts(ctx);
const sourceId = scope.sourceId ?? (scope.sourceIds?.[0]);
// v0.39 T19 + codex finding #2: pre-fix this collapsed sourceIds[] to
// the FIRST entry, which is arbitrary pack selection for a federated
// read. The correct behavior is: when federated_read is in play and
// sources have divergent active packs, REJECT the request with a
// permission_denied error pointing at v0.40+ per-source closure work.
// Single-source reads (scope.sourceId scalar) keep the v0.34.1 semantics.
let sourceId: string | undefined;
if (scope.sourceIds && scope.sourceIds.length > 0) {
if (scope.sourceIds.length === 1) {
sourceId = scope.sourceIds[0];
} else {
// Multi-source federated read: compare resolved pack names per
// source. If they all agree, use the first; if they diverge, fail
// closed with a permission_denied to surface the drift instead of
// arbitrary pack selection.
const { resolveActivePackName } = await import('./registry.ts');
const cfg = loadConfig();
const packNames = new Set<string>();
for (const sid of scope.sourceIds) {
const res = resolveActivePackName({
remote: ctx.remote ?? true,
envVar: process.env.GBRAIN_SCHEMA_PACK?.trim() || undefined,
sourceId: sid,
homeConfig: cfg?.schema_pack?.trim() || undefined,
});
packNames.add(res.pack_name);
}
if (packNames.size > 1) {
throw new SchemaPackTrustGateError(
`Federated read across ${scope.sourceIds.length} sources resolves to ${packNames.size} distinct packs (${[...packNames].join(', ')}). ` +
`Per-source closure across mounts ships in v0.40+. Until then, ` +
`register an OAuth client scoped to a single source OR have the sources agree on one pack.`,
);
}
sourceId = scope.sourceIds[0];
}
} else {
sourceId = scope.sourceId;
}
const input: LoadActivePackInput = {
cfg: loadConfig(),
remote: ctx.remote ?? true, // fail-closed default
+17
View File
@@ -489,6 +489,17 @@ export interface KnobsHashContext {
embeddingColumn?: string;
/** Resolved provider:model, e.g. 'voyage:voyage-3-large'. */
embeddingModel?: string;
/**
* v0.39 T21 + codex finding #5: cache + eval pack isolation. A cache
* row written when pack `garry-pack@1.2` was active must NEVER be
* served when pack `research-state@0.5` is active — they may resolve
* different type closures for the same query. The hash folds in
* pack name + version so cross-pack contamination is structurally
* impossible. Undefined falls back to the literal 'none' for
* backward compat with callers that don't yet thread pack identity.
*/
schemaPack?: string;
schemaPackVersion?: string;
}
export function knobsHash(
@@ -538,6 +549,12 @@ export function knobsHash(
// must never be served from a row that ran against `embedding`.
`col=${ctx?.embeddingColumn ?? 'embedding'}`,
`prov=${ctx?.embeddingModel ?? 'default'}`,
// v0.39 T21 + codex finding #5: schema-pack name + version. Cross-pack
// contamination is structurally impossible — a query that resolved
// type `researcher` against pack A cannot be served from a row that
// resolved against pack B.
`pack=${ctx?.schemaPack ?? 'none'}`,
`pver=${ctx?.schemaPackVersion ?? 'none'}`,
];
const h = createHash('sha256');
h.update(parts.join('|'));