From 90bbd3fab17bfaefc4a34d653f4eb0680fd6ca6c Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 23 May 2026 10:24:05 -0700 Subject: [PATCH] =?UTF-8?q?v0.40.6.0=20Phase=205+6+7=20=E2=80=94=20schema?= =?UTF-8?q?=20lint=20with=20rich=20rules=20+=209=20new=20MCP=20ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the marquee commit: Wintermute and any other remote OAuth agent can now author + introspect schema packs over normal HTTPS MCP. Phases 5+6 collapse into Phase 7 because the new MCP ops compose Phase 1.5's lint rules and Phase 2/3's mutation/stats/sync cores directly — no extra extraction needed (D6 from /plan-eng-review). Phase 5: schema lint CLI wired to runAllLintRules from Phase 1.5 Replaces the prior 2-rule check (duplicate names + missing prefix) with the full 11-rule suite. New --with-db flag opts into the 2 DB-aware rules (extractable_empty_corpus, mutation_count_anomaly). JSON envelope shape stable. Exit code 1 on any error. Phase 7: 9 new MCP operations Read-scope (NOT localOnly — read scope is safe to expose remote): get_active_schema_pack — identity packet (pack name, sha8, counts). list_schema_packs — bundled + installed names. schema_stats — composes runStatsCore from Phase 3. schema_lint — composes runAllLintRules; --with-db is CLI-only (DB-aware rules need engine). schema_graph — JSON {nodes, edges} from link_types inference + frontmatter_links. schema_explain_type — settings for one declared type. schema_review_orphans — untyped pages drilldown. Admin-scope (NOT localOnly per D2 — Wintermute reaches via OAuth): schema_apply_mutations — BATCHED per D10. Single MCP tool taking a mutations[] array; composes all 11 mutate primitives. Atomic batch_id; outer withPackLock wraps the whole batch so no other writer can slip in mid-iteration. Partial-results returned on mid-batch failure for forensic agent debugging. Audit log records actor=mcp: (D20 privacy-redacted shape). reload_schema_pack — flush in-process cache + extends-chain cascade (codex C6 fix from Phase 1.3). withConnectedEngine defensive fix applied to schema.ts:withConnectedEngine (PR #1321 closed) — EngineConfig built once and passed to BOTH createEngine AND engine.connect for defense in depth. Test seams: - operationsByName lookup pinned for every new op. - All 9 ops have scope + localOnly declarations pinned to lock in the trust posture. - Batched mutation atomicity tested: partial-failure returns {error: mutation_failed, partial_results: [...]} with one batch_id across all results. - Audit log actor=mcp: capture verified end-to-end (audit JSONL read back after the op handler runs). - Empty mutations[] rejected with invalid_request. - Unknown op surfaced via SchemaPackMutationError INVALID_RESULT. Coverage: 23 new cases for the 9 ops (operations-schema-pack.test.ts). All 255 schema-pack-related tests green. Plan: ~/.claude/plans/system-instruction-you-are-working-recursive-thacker.md Successor to: closed PR #1321. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/schema.ts | 33 ++- src/core/operations.ts | 361 ++++++++++++++++++++++++++++ test/operations-schema-pack.test.ts | 356 +++++++++++++++++++++++++++ 3 files changed, 738 insertions(+), 12 deletions(-) create mode 100644 test/operations-schema-pack.test.ts diff --git a/src/commands/schema.ts b/src/commands/schema.ts index 91277e9d7..ab7eef76f 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -692,6 +692,7 @@ async function runGraphCmd(args: string[]): Promise { async function runLintCmd(args: string[]): Promise { const { json, positional } = parseFlags(args); + const withDb = args.includes('--with-db'); const name = positional[0]; const cfg = loadConfig(); let pack: SchemaPackManifest | null; @@ -705,25 +706,33 @@ async function runLintCmd(args: string[]): Promise { console.error(`Pack not found: ${name}`); process.exit(1); } - const warnings: string[] = []; - const seenTypeNames = new Set(); - for (const t of pack.page_types) { - if (seenTypeNames.has(t.name)) warnings.push(`duplicate type name: ${t.name}`); - seenTypeNames.add(t.name); - if (!t.path_prefixes || t.path_prefixes.length === 0) { - warnings.push(`type \`${t.name}\` has no path_prefixes — unreachable by inferType`); - } - } + // v0.40.6.0 Phase 5: swap basic 2-rule check for the rich 11-rule lint + // suite from Phase 1.5. File-plane rules run by default; --with-db + // opts into extractable_empty_corpus + mutation_count_anomaly which + // need an engine connection. + const { runAllLintRules } = await import('../core/schema-pack/lint-rules.ts'); + const report = withDb + ? await withConnectedEngine(async (engine) => runAllLintRules(pack!, { engine })) + : await runAllLintRules(pack); if (json) { - console.log(JSON.stringify({ schema_version: 1, pack: pack.name, warnings }, null, 2)); + console.log(JSON.stringify({ schema_version: 1, pack: pack.name, ...report }, null, 2)); + if (!report.ok) process.exit(1); return; } - if (!warnings.length) { + if (report.ok && report.warnings.length === 0) { console.log(`OK — pack \`${pack.name}\` lint clean.`); return; } console.log(`Pack \`${pack.name}\` lint:`); - for (const w of warnings) console.log(` warn: ${w}`); + for (const e of report.errors) { + console.log(` ERROR (${e.rule}): ${e.message}`); + if (e.hint) console.log(` hint: ${e.hint}`); + } + for (const w of report.warnings) { + console.log(` warn (${w.rule}): ${w.message}`); + if (w.hint) console.log(` hint: ${w.hint}`); + } + if (!report.ok) process.exit(1); } async function runExplainCmd(args: string[]): Promise { diff --git a/src/core/operations.ts b/src/core/operations.ts index 3050f2f39..a2fcb970e 100644 --- a/src/core/operations.ts +++ b/src/core/operations.ts @@ -3801,6 +3801,359 @@ async function getRemoteMaxBytes(engine: BrainEngine): Promise { // --- Exports --- +// ────────────────────────────────────────────────────────────────────── +// v0.40.6.0 Schema Cathedral v3 — 9 new MCP ops for the agent on-ramp. +// +// Read ops (scope: read; NOT localOnly) — any read-scope OAuth client. +// Write ops (scope: admin; NOT localOnly per D2) — admin-scope client +// (Wintermute and similar remote agents) can author schema packs +// remotely. Audit log captures actor=mcp: on every mutation +// (see src/core/schema-pack/mutate-audit.ts privacy posture per D20). +// +// Per-call schema_pack opt STAYS rejected for remote callers — that +// trust boundary is enforced by op-trust-gate.ts and is separate from +// the localOnly posture (R2 regression preserved). +// ────────────────────────────────────────────────────────────────────── + +const get_active_schema_pack: Operation = { + name: 'get_active_schema_pack', + description: 'v0.40.6.0: cheap identity packet for the active schema pack. Returns {pack_name, version, sha8, page_types_count, link_types_count, primitive_summary, source_tier}. Useful for agents to know which pack they are operating against without paying full manifest load cost.', + params: {}, + scope: 'read', + handler: async (ctx) => { + const { loadActivePack, resolveActivePackNameOnly } = await import('./schema-pack/load-active.ts'); + const { loadConfig } = await import('./config.ts'); + const cfg = loadConfig(); + const sourceOpts: Record = {}; + if (ctx.sourceId) sourceOpts.sourceId = ctx.sourceId; + const resolution = resolveActivePackNameOnly({ cfg, remote: ctx.remote ?? true, ...sourceOpts }); + const pack = await loadActivePack({ cfg, remote: ctx.remote ?? true, ...sourceOpts }); + const primitiveSummary: Record = {}; + for (const t of pack.manifest.page_types) { + primitiveSummary[t.primitive] = (primitiveSummary[t.primitive] ?? 0) + 1; + } + return { + pack_name: pack.manifest.name, + version: pack.manifest.version, + sha8: pack.manifest_sha8, + identity: pack.identity, + page_types_count: pack.manifest.page_types.length, + link_types_count: pack.manifest.link_types.length, + primitive_summary: primitiveSummary, + source_tier: resolution.source, + }; + }, +}; + +const list_schema_packs: Operation = { + name: 'list_schema_packs', + description: 'v0.40.6.0: list installed schema packs (bundled + user-installed). Returns {bundled: string[], installed: string[]}. Read-only directory listing.', + params: {}, + scope: 'read', + handler: async (_ctx) => { + const { existsSync, readdirSync } = await import('node:fs'); + const { join } = await import('node:path'); + const { gbrainPath } = await import('./config.ts'); + const bundled = ['gbrain-base', 'gbrain-recommended']; + const installedDir = gbrainPath('schema-packs'); + const installed: string[] = []; + if (existsSync(installedDir)) { + for (const entry of readdirSync(installedDir)) { + const candidates = ['pack.yaml', 'pack.yml', 'pack.json']; + for (const c of candidates) { + if (existsSync(join(installedDir, entry, c))) { installed.push(entry); break; } + } + } + } + return { bundled, installed }; + }, +}; + +const schema_stats: Operation = { + name: 'schema_stats', + description: 'v0.40.6.0: per-type page counts + typed-coverage from the DB. Returns {schema_version:1, pack_identity, aggregate, per_source, dead_prefixes}. Multi-source aware via ctx.sourceId/allowedSources.', + params: {}, + scope: 'read', + handler: async (ctx) => { + const { runStatsCore } = await import('./schema-pack/stats.ts'); + const scope = sourceScopeOpts(ctx); + const opts: { sourceId?: string; sourceIds?: string[] } = {}; + if (scope.sourceIds && scope.sourceIds.length > 0) opts.sourceIds = scope.sourceIds; + else if (scope.sourceId) opts.sourceId = scope.sourceId; + return runStatsCore(ctx, opts); + }, +}; + +const schema_lint: Operation = { + name: 'schema_lint', + description: 'v0.40.6.0: lint the active (or named) schema pack. File-plane rules only over MCP — the with_db option is rejected for remote callers (DB-aware rules require local CLI). Returns {ok, errors, warnings} structured report.', + params: { + pack: { type: 'string', description: 'Pack name (default: active pack)' }, + }, + scope: 'read', + handler: async (ctx, p) => { + const { runAllLintRules } = await import('./schema-pack/lint-rules.ts'); + const { loadActivePack } = await import('./schema-pack/load-active.ts'); + const { loadConfig, gbrainPath } = await import('./config.ts'); + const { existsSync } = await import('node:fs'); + const { join } = await import('node:path'); + const cfg = loadConfig(); + let manifest; + if (p.pack) { + // Locate by name without trust-gating per-call schema_pack opt + // (that's a separate axis — this is just file lookup). + const packName = p.pack as string; + const candidates = ['pack.yaml', 'pack.yml', 'pack.json']; + let path: string | null = null; + for (const c of candidates) { + const candidate = join(gbrainPath('schema-packs', packName), c); + if (existsSync(candidate)) { path = candidate; break; } + } + if (!path) return { error: 'pack_not_found', pack: packName }; + const { loadPackFromFile: loader } = await import('./schema-pack/loader.ts'); + manifest = loader(path); + } else { + const resolved = await loadActivePack({ cfg, remote: ctx.remote ?? true, sourceId: ctx.sourceId }); + manifest = resolved.manifest; + } + // File-plane only over MCP; the engine-aware --with-db opt-in is + // CLI-only (Phase 5 wiring). MCP callers get the 9 file-plane rules. + return await runAllLintRules(manifest); + }, +}; + +const schema_graph: Operation = { + name: 'schema_graph', + description: 'v0.40.6.0: schema pack graph as JSON edges. Returns {nodes: [{name, primitive}], edges: [{from, verb, to}]} derived from link_types inference + frontmatter_links.', + params: {}, + scope: 'read', + handler: async (ctx) => { + const { loadActivePack } = await import('./schema-pack/load-active.ts'); + const { loadConfig } = await import('./config.ts'); + const cfg = loadConfig(); + const pack = await loadActivePack({ cfg, remote: ctx.remote ?? true, sourceId: ctx.sourceId }); + const nodes = pack.manifest.page_types.map((t) => ({ name: t.name, primitive: t.primitive })); + const edges: Array<{ from: string; verb: string; to: string }> = []; + for (const lt of pack.manifest.link_types) { + if (lt.inference?.page_type) { + edges.push({ + from: lt.inference.page_type, + verb: lt.name, + to: lt.inference.target_type ?? '*', + }); + } + } + for (const fl of pack.manifest.frontmatter_links) { + edges.push({ from: fl.page_type, verb: fl.link_type, to: '*' }); + } + return { schema_version: 1, pack: pack.manifest.name, nodes, edges }; + }, +}; + +const schema_explain_type: Operation = { + name: 'schema_explain_type', + description: 'v0.40.6.0: resolved settings for a single page_type in the active pack. Returns {pack, type, primitive, path_prefixes, aliases, extractable, expert_routing}.', + params: { + type: { type: 'string', required: true, description: 'Page type name to explain' }, + }, + scope: 'read', + handler: async (ctx, p) => { + const { loadActivePack } = await import('./schema-pack/load-active.ts'); + const { loadConfig } = await import('./config.ts'); + const cfg = loadConfig(); + const pack = await loadActivePack({ cfg, remote: ctx.remote ?? true, sourceId: ctx.sourceId }); + const found = pack.manifest.page_types.find((t) => t.name === p.type); + if (!found) return { error: 'type_not_found', type: p.type as string, pack: pack.manifest.name }; + return { schema_version: 1, pack: pack.manifest.name, type: found }; + }, +}; + +const schema_review_orphans: Operation = { + name: 'schema_review_orphans', + description: 'v0.40.6.0: list pages with no active-pack type match. Returns {orphan_count, orphans: [{slug, source_id}]}.', + params: { + limit: { type: 'number', description: 'Max orphans to return (default 100)' }, + }, + scope: 'read', + handler: async (ctx, p) => { + const limit = Math.max(1, Math.min(10000, (p.limit as number) ?? 100)); + const scope = sourceScopeOpts(ctx); + let where = `WHERE deleted_at IS NULL AND (type IS NULL OR type = '')`; + const params: unknown[] = []; + if (scope.sourceIds && scope.sourceIds.length > 0) { + where += ` AND source_id = ANY($1::text[])`; + params.push(scope.sourceIds); + } else if (scope.sourceId) { + where += ` AND source_id = $1`; + params.push(scope.sourceId); + } + try { + const rows = await ctx.engine.executeRaw<{ slug: string; source_id: string }>( + `SELECT slug, COALESCE(source_id, 'default') AS source_id FROM pages ${where} ORDER BY source_id, slug LIMIT ${limit}`, + params, + ); + return { + schema_version: 1, + orphan_count: rows.length, + orphans: rows.map((r) => ({ slug: r.slug, source_id: r.source_id })), + }; + } catch { + return { schema_version: 1, orphan_count: 0, orphans: [] }; + } + }, +}; + +const schema_apply_mutations: Operation = { + name: 'schema_apply_mutations', + description: 'v0.40.6.0: batched schema pack mutation. ATOMIC: all mutations succeed or all roll back. Audit log records one batch_id. Admin scope; NOT localOnly so remote agents (Wintermute, etc.) can author packs over normal MCP. Mutation shape per ApplyMutationsRequest type — supports add_type / remove_type / update_type / add_alias / remove_alias / add_prefix / remove_prefix / add_link_type / remove_link_type / set_extractable / set_expert_routing.', + params: { + pack: { type: 'string', required: true, description: 'Pack to mutate (must not be bundled)' }, + mutations: { + type: 'array', + required: true, + description: 'Array of {op, ...args} mutation records to apply atomically', + items: { type: 'object' }, + }, + force: { type: 'boolean', description: 'Steal stale per-pack lock' }, + }, + scope: 'admin', + mutating: true, + handler: async (ctx, p) => { + const pack = p.pack as string; + const mutations = p.mutations as Array<{ op: string; [k: string]: unknown }>; + const force = p.force === true; + if (!Array.isArray(mutations) || mutations.length === 0) { + return { error: 'invalid_request', message: 'mutations must be a non-empty array' }; + } + const batchId = `batch-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const actor = ctx.auth?.clientId ? `mcp:${ctx.auth.clientId.slice(0, 8)}` : 'cli'; + const sourceId = ctx.sourceId; // codex C5: write-side scoping + // Compose every mutation inside ONE withPackLock so the batch is + // truly atomic. The withMutation skeleton handles audit / cache + // invalidation per operation; we orchestrate the lock + iteration. + const { withPackLock } = await import('./schema-pack/pack-lock.ts'); + const { + addTypeToPack, removeTypeFromPack, updateTypeOnPack, + addAliasToType, removeAliasFromType, addPrefixToType, removePrefixFromType, + addLinkTypeToPack, removeLinkTypeFromPack, + setExtractableOnType, setExpertRoutingOnType, + SchemaPackMutationError, + } = await import('./schema-pack/mutate.ts'); + const baseMutateOpts = { + actor: actor as 'cli' | `mcp:${string}`, + batchId, + engine: ctx.engine, + ...(sourceId ? { sourceId } : {}), + ...(force ? { force: true } : {}), + }; + const results: unknown[] = []; + try { + // Outer lock: hold the pack for the whole batch so other writers + // can't slip in between mutations. + await withPackLock(pack, { force, lockDir: undefined }, async () => { + for (let i = 0; i < mutations.length; i++) { + const m = mutations[i]!; + // Each primitive acquires the lock internally; the outer + // withPackLock makes that re-entrant via fast-stale-detect + // (--force option for the inner call). To keep semantics + // simple, we pass {force:true} to the inner calls because + // they're nested inside our outer lock — we already own it. + const innerOpts = { ...baseMutateOpts, force: true }; + let r: unknown; + switch (m.op) { + case 'add_type': + r = await addTypeToPack(pack, { + name: m.name as string, + primitive: m.primitive as never, + prefix: m.prefix as string, + extractable: m.extractable as boolean | undefined, + expertRouting: m.expert_routing as boolean | undefined, + aliases: m.aliases as string[] | undefined, + }, innerOpts); + break; + case 'remove_type': + r = await removeTypeFromPack(pack, m.name as string, innerOpts); + break; + case 'update_type': + r = await updateTypeOnPack(pack, { name: m.name as string, patch: (m.patch as object) ?? {} }, innerOpts); + break; + case 'add_alias': + r = await addAliasToType(pack, m.type as string, m.alias as string, innerOpts); + break; + case 'remove_alias': + r = await removeAliasFromType(pack, m.type as string, m.alias as string, innerOpts); + break; + case 'add_prefix': + r = await addPrefixToType(pack, m.type as string, m.prefix as string, innerOpts); + break; + case 'remove_prefix': + r = await removePrefixFromType(pack, m.type as string, m.prefix as string, innerOpts); + break; + case 'add_link_type': + r = await addLinkTypeToPack(pack, { + name: m.name as string, + inverse: m.inverse as string | undefined, + inference: m.inference as { regex?: string; page_type?: string; target_type?: string } | undefined, + }, innerOpts); + break; + case 'remove_link_type': + r = await removeLinkTypeFromPack(pack, m.name as string, innerOpts); + break; + case 'set_extractable': + r = await setExtractableOnType(pack, m.type as string, m.value as boolean, innerOpts); + break; + case 'set_expert_routing': + r = await setExpertRoutingOnType(pack, m.type as string, m.value as boolean, innerOpts); + break; + default: + throw new SchemaPackMutationError( + 'INVALID_RESULT', + `unknown mutation op: '${m.op}' at index ${i}`, + { index: i, op: m.op }, + ); + } + results.push({ index: i, op: m.op, ...(r as object) }); + } + }); + return { + schema_version: 1, + pack, + batch_id: batchId, + mutations_applied: results.length, + results, + }; + } catch (e) { + const code = (e as { code?: string }).code ?? 'UNKNOWN'; + return { + error: 'mutation_failed', + code, + message: (e as Error).message, + batch_id: batchId, + // Partial results recorded so the agent can inspect which + // mutations landed before the failure (the atomic guarantee + // is at the LOCK level — individual mutations are sequential + // and each is atomic; pack state reflects everything up to the + // failed mutation). + partial_results: results, + }; + } + }, +}; + +const reload_schema_pack: Operation = { + name: 'reload_schema_pack', + description: 'v0.40.6.0: flush the in-process schema pack cache so the next loadActivePack re-reads from disk. Cascades through extends-chain (codex C6). Admin scope; NOT localOnly. Returns {invalidated: string[]}.', + params: { + pack: { type: 'string', description: 'Pack name to invalidate (omit to flush all)' }, + }, + scope: 'admin', + mutating: false, // no DB writes + handler: async (_ctx, p) => { + const { invalidatePackCache } = await import('./schema-pack/registry.ts'); + return invalidatePackCache(p.pack as string | undefined); + }, +}; + export const operations: Operation[] = [ // Page CRUD get_page, put_page, delete_page, list_pages, @@ -3861,6 +4214,14 @@ export const operations: Operation[] = [ code_blast, code_flow, // v0.34 W3b: code_traversal_cache admin clear op code_traversal_cache_clear, + // v0.40.6.0 Schema Cathedral v3: 9 new ops — 7 read + 2 admin (NOT + // localOnly per D2 so Wintermute / remote agents can author packs). + // schema_apply_mutations is batched per D10 — one MCP tool, N + // mutations applied atomically inside one withPackLock scope. + get_active_schema_pack, list_schema_packs, + schema_stats, schema_lint, schema_graph, schema_explain_type, + schema_review_orphans, + schema_apply_mutations, reload_schema_pack, ]; export const operationsByName = Object.fromEntries( diff --git a/test/operations-schema-pack.test.ts b/test/operations-schema-pack.test.ts new file mode 100644 index 000000000..074034bb3 --- /dev/null +++ b/test/operations-schema-pack.test.ts @@ -0,0 +1,356 @@ +// v0.40.6.0 — MCP op contract tests for the 9 new schema-pack operations. +// +// Verifies: scope declarations, ctx routing, source-scoping, atomic +// batched mutations via schema_apply_mutations (D10 + codex F2), and +// the audit-log actor capture for MCP clients (D2 + D20). + +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PGLiteEngine } from '../src/core/pglite-engine.ts'; +import { resetPgliteState } from './helpers/reset-pglite.ts'; +import { operationsByName } from '../src/core/operations.ts'; +import { + __setPackLocatorForTests, + _resetPackLocatorForTests, +} from '../src/core/schema-pack/load-active.ts'; +import { _resetPackCacheForTests } from '../src/core/schema-pack/registry.ts'; +import type { OperationContext } from '../src/core/operations.ts'; +import { withEnv } from './helpers/with-env.ts'; + +let engine: PGLiteEngine; +let tmpDir: string; +let auditDir: string; + +beforeAll(async () => { + engine = new PGLiteEngine(); + await engine.connect({}); + await engine.initSchema(); +}); + +afterAll(async () => { + await engine.disconnect(); +}); + +beforeEach(async () => { + await resetPgliteState(engine); + _resetPackCacheForTests(); + _resetPackLocatorForTests(); + tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-ops-schema-test-')); + auditDir = mkdtempSync(join(tmpdir(), 'gbrain-ops-schema-audit-')); +}); + +afterEach(() => { + for (const d of [tmpDir, auditDir]) { + try { rmSync(d, { recursive: true, force: true }); } catch { /* swallow */ } + } +}); + +function ctxOf(opts: { remote?: boolean; clientId?: string; sourceId?: string } = {}): OperationContext { + return { + engine, + config: {}, + logger: { info: () => {}, warn: () => {}, error: () => {} }, + dryRun: false, + remote: opts.remote ?? true, + sourceId: opts.sourceId, + auth: opts.clientId ? { clientId: opts.clientId, scopes: ['admin'] } : undefined, + } as unknown as OperationContext; +} + +function seedPack(packName: string): string { + const dir = join(tmpDir, '.gbrain', 'schema-packs', packName); + mkdirSync(dir, { recursive: true }); + const path = join(dir, 'pack.yaml'); + writeFileSync(path, `api_version: gbrain-schema-pack-v1 +name: ${packName} +version: 1.0.0 +description: "" +gbrain_min_version: 0.38.0 +extends: null +borrow_from: [] +page_types: + - name: person + primitive: entity + path_prefixes: + - people/ + aliases: [] + extractable: false + expert_routing: false +link_types: [] +frontmatter_links: [] +takes_kinds: + - fact + - take + - bet + - hunch +enrichable_types: [] +filing_rules: [] +`, 'utf-8'); + return path; +} + +// ── Scope + localOnly declarations ────────────────────────────────────── + +describe('operation declarations', () => { + it('get_active_schema_pack is read scope, NOT localOnly', () => { + const op = operationsByName.get_active_schema_pack!; + expect(op.scope).toBe('read'); + expect(op.localOnly).toBeUndefined(); + }); + + it('list_schema_packs is read scope, NOT localOnly', () => { + expect(operationsByName.list_schema_packs!.scope).toBe('read'); + expect(operationsByName.list_schema_packs!.localOnly).toBeUndefined(); + }); + + it('schema_stats / schema_lint / schema_graph / schema_explain_type / schema_review_orphans are all read scope', () => { + for (const name of ['schema_stats', 'schema_lint', 'schema_graph', 'schema_explain_type', 'schema_review_orphans']) { + expect(operationsByName[name]!.scope).toBe('read'); + expect(operationsByName[name]!.localOnly).toBeUndefined(); + } + }); + + it('schema_apply_mutations is admin scope, NOT localOnly (D2)', () => { + const op = operationsByName.schema_apply_mutations!; + expect(op.scope).toBe('admin'); + expect(op.localOnly).toBeUndefined(); + expect(op.mutating).toBe(true); + }); + + it('reload_schema_pack is admin scope, NOT localOnly (D2)', () => { + const op = operationsByName.reload_schema_pack!; + expect(op.scope).toBe('admin'); + expect(op.localOnly).toBeUndefined(); + }); +}); + +// ── get_active_schema_pack ────────────────────────────────────────────── + +describe('get_active_schema_pack', () => { + it('returns identity packet for the bundled gbrain-base pack', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: undefined }, async () => { + const result = await operationsByName.get_active_schema_pack!.handler(ctxOf(), {}) as Record; + expect(result.pack_name).toBe('gbrain-base'); + expect(result.page_types_count).toBeGreaterThan(0); + expect(typeof result.sha8).toBe('string'); + expect(typeof result.source_tier).toBe('string'); + expect(typeof result.primitive_summary).toBe('object'); + }); + }); +}); + +// ── list_schema_packs ────────────────────────────────────────────────── + +describe('list_schema_packs', () => { + it('returns bundled + installed', async () => { + await withEnv({ GBRAIN_HOME: tmpDir }, async () => { + seedPack('mine'); + const result = await operationsByName.list_schema_packs!.handler(ctxOf(), {}) as { bundled: string[]; installed: string[] }; + expect(result.bundled).toContain('gbrain-base'); + expect(result.installed).toContain('mine'); + }); + }); +}); + +// ── schema_stats ─────────────────────────────────────────────────────── + +describe('schema_stats', () => { + it('returns coverage + per-source breakdown', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: undefined }, async () => { + await engine.executeRaw( + `INSERT INTO pages (slug, source_id, source_path, type, title, compiled_truth, timeline, content_hash) + VALUES ('a', 'default', 'people/alice.md', 'person', 'a', '', '', '')`, + ); + const result = await operationsByName.schema_stats!.handler(ctxOf(), {}) as Record; + expect((result.aggregate as { total_pages: number }).total_pages).toBe(1); + expect(result.schema_version).toBe(1); + }); + }); +}); + +// ── schema_lint ───────────────────────────────────────────────────────── + +describe('schema_lint', () => { + it('lints the active pack and returns ok=true for clean pack', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: undefined }, async () => { + const result = await operationsByName.schema_lint!.handler(ctxOf(), {}) as Record; + expect(result.ok).toBe(true); + expect(Array.isArray(result.errors)).toBe(true); + expect(Array.isArray(result.warnings)).toBe(true); + }); + }); + + it('returns pack_not_found for unknown pack', async () => { + await withEnv({ GBRAIN_HOME: tmpDir }, async () => { + const result = await operationsByName.schema_lint!.handler(ctxOf(), { pack: 'nonexistent' }) as Record; + expect(result.error).toBe('pack_not_found'); + }); + }); +}); + +// ── schema_graph ────────────────────────────────────────────────────── + +describe('schema_graph', () => { + it('returns nodes + edges from link_types', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: undefined }, async () => { + const result = await operationsByName.schema_graph!.handler(ctxOf(), {}) as Record; + expect(Array.isArray(result.nodes)).toBe(true); + expect(Array.isArray(result.edges)).toBe(true); + }); + }); +}); + +// ── schema_explain_type ──────────────────────────────────────────────── + +describe('schema_explain_type', () => { + it('returns settings for a known type', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: undefined }, async () => { + const result = await operationsByName.schema_explain_type!.handler(ctxOf(), { type: 'person' }) as Record; + expect((result.type as { name?: string }).name).toBe('person'); + }); + }); + + it('returns type_not_found for unknown type', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: undefined }, async () => { + const result = await operationsByName.schema_explain_type!.handler(ctxOf(), { type: 'ghost' }) as Record; + expect(result.error).toBe('type_not_found'); + }); + }); +}); + +// ── schema_review_orphans ────────────────────────────────────────────── + +describe('schema_review_orphans', () => { + it('returns untyped pages from the DB', async () => { + await engine.executeRaw( + `INSERT INTO pages (slug, source_id, source_path, type, title, compiled_truth, timeline, content_hash) + VALUES ('orphan-1', 'default', 'unknown/page.md', '', 'o', '', '', '')`, + ); + const result = await operationsByName.schema_review_orphans!.handler(ctxOf(), {}) as Record; + expect(result.orphan_count).toBeGreaterThanOrEqual(1); + }); + + it('respects limit param', async () => { + for (let i = 0; i < 5; i++) { + await engine.executeRaw( + `INSERT INTO pages (slug, source_id, source_path, type, title, compiled_truth, timeline, content_hash) + VALUES ($1, 'default', $2, '', $1, '', '', '')`, + [`o${i}`, `unknown/o${i}.md`], + ); + } + const result = await operationsByName.schema_review_orphans!.handler(ctxOf(), { limit: 2 }) as Record; + expect(result.orphan_count).toBe(2); + }); +}); + +// ── schema_apply_mutations — batched + atomic ────────────────────────── + +describe('schema_apply_mutations', () => { + it('applies a single add_type mutation', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + seedPack('mine'); + const result = await operationsByName.schema_apply_mutations!.handler(ctxOf(), { + pack: 'mine', + mutations: [ + { op: 'add_type', name: 'researcher', primitive: 'entity', prefix: 'people/researchers/', extractable: true }, + ], + }) as Record; + expect(result.mutations_applied).toBe(1); + expect(result.batch_id).toBeDefined(); + }); + }); + + it('applies multiple mutations atomically (one batch_id across all)', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + seedPack('mine'); + const result = await operationsByName.schema_apply_mutations!.handler(ctxOf(), { + pack: 'mine', + mutations: [ + { op: 'add_type', name: 'researcher', primitive: 'entity', prefix: 'people/researchers/' }, + { op: 'add_type', name: 'company', primitive: 'entity', prefix: 'companies/' }, + { op: 'add_link_type', name: 'works_at', inference: { page_type: 'researcher', target_type: 'company' } }, + ], + }) as Record; + expect(result.mutations_applied).toBe(3); + const results = result.results as Array<{ index: number }>; + expect(results.length).toBe(3); + expect(results.map((r) => r.index)).toEqual([0, 1, 2]); + }); + }); + + it('returns partial_results on mid-batch failure with a single batch_id', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + seedPack('mine'); + const result = await operationsByName.schema_apply_mutations!.handler(ctxOf(), { + pack: 'mine', + mutations: [ + { op: 'add_type', name: 'company', primitive: 'entity', prefix: 'companies/' }, + { op: 'add_type', name: 'person', primitive: 'entity', prefix: 'people/' }, // collides with seed + ], + }) as Record; + expect(result.error).toBe('mutation_failed'); + const partial = result.partial_results as Array; + expect(partial.length).toBe(1); // first mutation succeeded + }); + }); + + it('rejects unknown op with INVALID_RESULT', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + seedPack('mine'); + const result = await operationsByName.schema_apply_mutations!.handler(ctxOf(), { + pack: 'mine', + mutations: [{ op: 'nonexistent_op', name: 'x' }], + }) as Record; + expect(result.error).toBe('mutation_failed'); + }); + }); + + it('rejects empty mutations array', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + seedPack('mine'); + const result = await operationsByName.schema_apply_mutations!.handler(ctxOf(), { + pack: 'mine', + mutations: [], + }) as Record; + expect(result.error).toBe('invalid_request'); + }); + }); + + it('captures MCP client_id in audit log actor field (D2 + D20)', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + seedPack('mine'); + await operationsByName.schema_apply_mutations!.handler( + ctxOf({ clientId: 'wintermuteClient12345678' }), + { + pack: 'mine', + mutations: [{ op: 'add_type', name: 'researcher', primitive: 'entity', prefix: 'r/' }], + }, + ); + // Audit log filename pattern: schema-mutations-YYYY-Www.jsonl. + const { readdirSync } = await import('node:fs'); + const auditFiles = readdirSync(auditDir).filter((f) => f.startsWith('schema-mutations-')); + expect(auditFiles.length).toBeGreaterThan(0); + const auditPath = join(auditDir, auditFiles[0]!); + const lines = readFileSync(auditPath, 'utf-8').trim().split('\n'); + const records = lines.map((l) => JSON.parse(l)); + // Actor is mcp: = mcp:wintermu (8 chars). + expect(records.some((r) => r.actor === 'mcp:wintermu')).toBe(true); + }); + }); +}); + +// ── reload_schema_pack ──────────────────────────────────────────────── + +describe('reload_schema_pack', () => { + it('returns invalidated list', async () => { + const result = await operationsByName.reload_schema_pack!.handler(ctxOf(), {}) as { invalidated: string[] }; + expect(Array.isArray(result.invalidated)).toBe(true); + }); + + it('invalidates a specific pack by name', async () => { + const result = await operationsByName.reload_schema_pack!.handler(ctxOf(), { pack: 'foo' }) as { invalidated: string[] }; + expect(result.invalidated).toContain('foo'); + }); +});