Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/adr/0040-codedb-stays-when-licensed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# 0040. Native codedb (and read_file) stay the default readers

Status: accepted 2026-08-27

## Context

v0.0.277 hid and refused native `codedb` and `read_file` whenever
`codedb-pro probe` succeeded, so a licensed suite was the only read
surface. `rlm codedb(...)` hit the same exec gate. A walk of a foreign
repo then failed with "codedb is blocked while the LICENSED
code-intelligence suite is in charge" and fell through to metered
`inspect`/`search`. Native codedb is local, indexed, and free.

## Decision

Ordinary reads use native `codedb` or `read_file`, licensed or not,
including as rlm host functions. Do not hide, refuse, or redirect those
to `mcp__codedbpro__read`. codedb-pro is extra search/batch
(`faster_search`, `meta_search`) when codedb cannot answer — not the
default reader. Leading shell `grep`/`rg` may still point at zigrep.
Companion write tools stay hidden (they bypass `/rewind`).

## Consequences

- A licensed session can `codedb context` / `around` / `callpath` /
`list_dir` / `status` and `read_file` without loading MCP schemas.
- `cat`/`head` of source are not rewritten to pro read; #626 still
steers a concrete source-file scan toward codedb.
- Do not re-add `codedb` or `read_file` to `hideBuiltin` or
`replacedNative`.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ record only when you need the evidence or the edge cases.
| [0036](0036-computer-use-keeps-the-signed-codex-bridge.md) | Codex Computer Use keeps its authenticated node_repl process chain: Graff launches it through the signed Codex sandbox wrapper, never embeds V8 or spoofs the service. |
| [0037](0037-experiment-pool-is-opt-in.md) | `--experiment N` / `/experiment N` pre-mints a child worktree pool; default isolation stays `shared_cwd`. 279 continuation — not on main until a later cut. |
| [0038](0038-in-process-acp-core.md) | Same-process embed is `libgraff` + `graff-core.wasm` + `createGraffAgent()` (ACP core, echo turn). Live coding stays `graff acp`. 279 continuation — not on main until a later cut. |
| [0040](0040-codedb-stays-when-licensed.md) | Ordinary reads use native `codedb` / `read_file`; codedb-pro is extra search, not the default reader. |

## When to write one

Expand Down
91 changes: 24 additions & 67 deletions src/codedbpro_report.zig
Original file line number Diff line number Diff line change
Expand Up @@ -142,11 +142,11 @@ pub fn fallbackOpen() bool {
return g_fallback_open.load(.acquire);
}

/// The native tools the licensed pro tools replace. edit_file/write_file are
/// deliberately NOT here: edits stay native regardless (they are
/// /rewind-snapshotted; codedb-pro edits bypass /rewind).
/// Native reads (`codedb`, `read_file`) are never replaced (ADR 0040).
/// edit_file/write_file stay native too (/rewind; pro edits bypass it).
fn replacedNative(name: []const u8) bool {
return std.mem.eql(u8, name, "read_file") or std.mem.eql(u8, name, "codedb");
_ = name;
return false;
}

/// A bash call is only intercepted when the search command LEADS the line —
Expand All @@ -156,7 +156,7 @@ fn leadingSearchCommand(cmd: []const u8) bool {
const trimmed = std.mem.trimStart(u8, cmd, " \t");
const end = std.mem.indexOfAny(u8, trimmed, " \t") orelse trimmed.len;
const first = trimmed[0..end];
return @import("tool_surface.zig").isSearchBash(first);
return @import("tool_surface.zig").isContentSearchBash(first);
}

/// Whether the licensed-pro enforcement applies to this call right now.
Expand All @@ -172,14 +172,14 @@ fn enforcementActive(ctx: tools.ToolCtx) bool {

/// What a blocked call is pointed at. Shell searches go to zigrep — the
/// suite's own CLI, run directly via bash, no MCP round trip — when the
/// binary is on PATH; reads and the codedb tool point at the pro MCP tools.
/// binary is on PATH. Ordinary reads stay on codedb / read_file.
fn replacementFor(call_name: []const u8, is_bash_search: bool, zigrep_installed: bool) []const u8 {
_ = call_name;
if (is_bash_search) return if (zigrep_installed)
"zigrep — run it directly via bash (e.g. `zigrep PATTERN src/`); it is the suite's search CLI. Caveat: zigrep always skips vendor dirs (node_modules & co) even with --no-ignore — use `rg -uu` for vendor dives"
else
"mcp__codedbpro__faster_search / meta_search";
if (std.mem.eql(u8, call_name, "read_file")) return "mcp__codedbpro__read (mode=outline first, then symbol/lines)";
return "mcp__codedbpro__faster_search / meta_search";
"codedb, or mcp__codedbpro__faster_search / meta_search when codedb cannot answer";
return "codedb or read_file";
}

/// exec.zig consults this on every tool call: when the licensed pro tools are
Expand Down Expand Up @@ -220,52 +220,18 @@ pub fn licensedGate(ctx: tools.ToolCtx, call: tools.ToolCall) ?tools.ToolOutput

pub const Redirect = struct { name: []const u8, input: std.json.Value };

fn readRedirect(gpa: Allocator, file: []const u8, mode: []const u8) ?Redirect {
var obj: std.json.ObjectMap = .empty;
obj.put(gpa, "file", .{ .string = file }) catch return null;
obj.put(gpa, "mode", .{ .string = mode }) catch return null;
return .{ .name = "mcp__codedbpro__read", .input = .{ .object = obj } };
}

fn searchRedirect(gpa: Allocator, pattern: []const u8) ?Redirect {
if (pattern.len == 0) return null;
var obj: std.json.ObjectMap = .empty;
obj.put(gpa, "pattern", .{ .string = pattern }) catch return null;
return .{ .name = "mcp__codedbpro__faster_search", .input = .{ .object = obj } };
}

/// exec.zig consults this BEFORE nativeRefusal — the guard's premise made
/// executable (user direction): a blocked native read/search becomes its
/// codedb-pro equivalent inline, skipping the refuse → load → re-call round
/// trip. null = no clean translation; the caller keeps the refusal. Edits
/// never redirect (native edit tools own /rewind). The dispatch site skips
/// the schema gate: arguments are harness-built on the daemon's contract.
/// Leading `grep`/`rg` may become zigrep / faster_search. Ordinary reads
/// (`read_file`, `codedb`, `cat`) never redirect to mcp__codedbpro__read.
pub fn redirect(ctx: tools.ToolCtx, call: tools.ToolCall) ?Redirect {
if (!enforcementActive(ctx)) return null;
const gpa = ctx.gpa;
if (std.mem.eql(u8, call.name, "read_file")) {
const path = tools.strField(call.input, "path") orelse return null;
if (tools.intField(call.input, "start_line")) |s| {
// lines mode takes a "N-M" range string (handler_read.zig).
const e = tools.intField(call.input, "end_line") orelse s + 400;
var obj: std.json.ObjectMap = .empty;
obj.put(gpa, "file", .{ .string = path }) catch return null;
obj.put(gpa, "mode", .{ .string = "lines" }) catch return null;
obj.put(gpa, "range", .{ .string = std.fmt.allocPrint(gpa, "{d}-{d}", .{ s, e }) catch return null }) catch return null;
return .{ .name = "mcp__codedbpro__read", .input = .{ .object = obj } };
}
return readRedirect(gpa, path, "full");
}
if (std.mem.eql(u8, call.name, "codedb")) {
const cmd = tools.strField(call.input, "command") orelse return null;
const sub_end = std.mem.indexOfAny(u8, cmd, " \t") orelse cmd.len;
const sub = cmd[0..sub_end];
const rest = std.mem.trim(u8, cmd[sub_end..], " \t");
if (std.mem.eql(u8, sub, "outline")) return readRedirect(gpa, rest, "outline");
if (std.mem.eql(u8, sub, "read")) return readRedirect(gpa, rest, "full");
if (std.mem.eql(u8, sub, "search")) return searchRedirect(gpa, rest);
return null; // symbol/callers/deps/tree have no lossless map — refusal stays
}
if (std.mem.eql(u8, call.name, "bash")) {
const cmd = tools.strField(call.input, "command") orelse return null;
if (!leadingSearchCommand(cmd)) return null;
Expand Down Expand Up @@ -436,11 +402,11 @@ fn namedCall(name: []const u8) tools.ToolCall {
return .{ .id = "t", .name = name, .input = .null };
}

test "replacementFor: shell searches point at zigrep directly, reads at the pro MCP tools" {
test "replacementFor: shell searches point at zigrep; reads stay native" {
try std.testing.expect(std.mem.indexOf(u8, replacementFor("bash", true, true), "zigrep") != null);
try std.testing.expect(std.mem.indexOf(u8, replacementFor("bash", true, false), "mcp__codedbpro__faster_search") != null);
try std.testing.expect(std.mem.indexOf(u8, replacementFor("read_file", false, true), "mcp__codedbpro__read") != null);
try std.testing.expect(std.mem.indexOf(u8, replacementFor("codedb", false, true), "mcp__codedbpro__faster_search") != null);
try std.testing.expect(std.mem.indexOf(u8, replacementFor("bash", true, false), "codedb") != null);
try std.testing.expect(std.mem.indexOf(u8, replacementFor("read_file", false, true), "read_file") != null);
try std.testing.expect(std.mem.indexOf(u8, replacementFor("codedb", false, true), "codedb") != null);
}

test "nativeRefusal: licensed pro tools block the natives they replaced" {
Expand All @@ -461,12 +427,12 @@ test "nativeRefusal: licensed pro tools block the natives they replaced" {
main_mod.plan_mode = false;
g_fallback_open.store(false, .release);

try std.testing.expect(nativeRefusal(ctx, namedCall("read_file")) != null);
try std.testing.expect(nativeRefusal(ctx, namedCall("codedb")) != null);
try std.testing.expect(nativeRefusal(ctx, bashCall(a, "find . -name '*.zig'")) != null);
try std.testing.expect(nativeRefusal(ctx, namedCall("read_file")) == null);
try std.testing.expect(nativeRefusal(ctx, namedCall("codedb")) == null);
try std.testing.expect(nativeRefusal(ctx, bashCall(a, "find . -name '*.zig'")) == null);
try std.testing.expect(nativeRefusal(ctx, bashCall(a, "rg TODO src")) != null);
try std.testing.expect(nativeRefusal(ctx, bashCall(a, "cat src/main.zig")) != null);
try std.testing.expect(nativeRefusal(ctx, bashCall(a, "head src/main.zig")) != null);
try std.testing.expect(nativeRefusal(ctx, bashCall(a, "cat src/main.zig")) == null);
try std.testing.expect(nativeRefusal(ctx, bashCall(a, "head src/main.zig")) == null);
try std.testing.expect(nativeRefusal(ctx, namedCall("edit_file")) == null); // edits stay native
try std.testing.expect(nativeRefusal(ctx, bashCall(a, "git status")) == null);
try std.testing.expect(nativeRefusal(ctx, bashCall(a, "curl -s x | grep err")) == null);
Expand Down Expand Up @@ -494,19 +460,10 @@ test "redirect: blocked natives translate to their codedb-pro equivalents" {
main_mod.plan_mode = false;
g_fallback_open.store(false, .release);

const r1 = redirect(ctx, jsonCall(a, "read_file", "{\"path\":\"src/main.zig\"}")).?;
try std.testing.expectEqualStrings("mcp__codedbpro__read", r1.name);
try std.testing.expectEqualStrings("full", r1.input.object.get("mode").?.string);
try std.testing.expectEqualStrings("src/main.zig", r1.input.object.get("file").?.string);
const r2 = redirect(ctx, jsonCall(a, "read_file", "{\"path\":\"a.zig\",\"start_line\":10,\"end_line\":40}")).?;
try std.testing.expectEqualStrings("lines", r2.input.object.get("mode").?.string);
try std.testing.expectEqualStrings("10-40", r2.input.object.get("range").?.string);
const r3 = redirect(ctx, jsonCall(a, "codedb", "{\"command\":\"outline src/main.zig\"}")).?;
try std.testing.expectEqualStrings("outline", r3.input.object.get("mode").?.string);
const r4 = redirect(ctx, jsonCall(a, "codedb", "{\"command\":\"search parseHeader\"}")).?;
try std.testing.expectEqualStrings("mcp__codedbpro__faster_search", r4.name);
try std.testing.expectEqualStrings("parseHeader", r4.input.object.get("pattern").?.string);
try std.testing.expect(redirect(ctx, jsonCall(a, "codedb", "{\"command\":\"callers foo\"}")) == null);
try std.testing.expect(redirect(ctx, jsonCall(a, "read_file", "{\"path\":\"src/main.zig\"}")) == null);
try std.testing.expect(redirect(ctx, jsonCall(a, "read_file", "{\"path\":\"a.zig\",\"start_line\":10,\"end_line\":40}")) == null);
try std.testing.expect(redirect(ctx, jsonCall(a, "codedb", "{\"command\":\"outline src/main.zig\"}")) == null);
try std.testing.expect(redirect(ctx, jsonCall(a, "codedb", "{\"command\":\"search parseHeader\"}")) == null);
// leading grep/rg → faster_search on the first bare token; find and piped grep stay out
const r5 = redirect(ctx, bashCall(a, "rg TODO src")).?;
try std.testing.expectEqualStrings("TODO", r5.input.object.get("pattern").?.string);
Expand Down
11 changes: 4 additions & 7 deletions src/session_start.zig
Original file line number Diff line number Diff line change
Expand Up @@ -487,13 +487,10 @@ pub fn probeLicensed(gpa: Allocator, io: Io) bool {
return skills.probeCodedbproLicensed(gpa, io);
}

/// A licensed codedb-pro is IN CHARGE of reads/searches (its guard refuses
/// the native codedb/read_file and points at these tools), which inverts the
/// #416 deferral premise: this is not a server "most sessions never call" —
/// the guard makes it mandatory, and the load_tool_schemas discovery dance is
/// a measured ~2 model round-trips (~8-12s) per task on K3. Pin it eager.
/// (#476 kept it deferred on a "zero discovery turns" claim; the benchmark
/// traces show every code-reading run paying the dance.)
/// A licensed codedb-pro still pins these tools eager (search/batch extras;
/// ADR 0040: not the default reader). Skipping that pin costs a measured
/// ~2 load_tool_schemas discovery turns per task that does need pro search.
/// (#476 kept it deferred; traces showed every such run paying the dance.)
pub fn pinCompanionEager(arena: Allocator) void {
if (mcp_schema_gate.pinnedEager("codedbpro")) return; // env/config already pinned
const gate = &mcp_schema_gate.g_policy;
Expand Down
18 changes: 9 additions & 9 deletions src/skills.zig
Original file line number Diff line number Diff line change
Expand Up @@ -60,23 +60,21 @@ pub const skills_registry = [_]SkillDef{
/// model knows when to reach for its tools. The native tools stay registered
/// regardless — they are the fallback whenever an MCP call fails, is denied,
/// or the server is disconnected/skipped.
/// Licensed-aware variant of the codedbpro note. When `codedb-pro probe`
/// succeeds (paid + usable) we inject THIS instead of the conservative
/// "prefer free codedb" note below — leaning into the tools the user pays for.
/// Edits inside the cwd stay native: edit_file/write_file are
/// /rewind-snapshotted and already splice via zigpatch, whereas codedb-pro
/// edit/patch/replace bypass /rewind. Explicit external targets use gated bash.
const codedbpro_note_licensed = "The codedb-pro MCP server is connected and LICENSED — its mcp__codedbpro__* tools (load once per session via load_tool_schemas, e.g. by query) REPLACE native read/search (read_file and the legacy codedb tool are hidden). Shell cat/grep/sed/head of source is redirected or refused. KEEP EDITS on native edit_file/write_file — codedb-pro write tools are hidden because they bypass /rewind. Size reads to the file: mode=full in ONE call for small files — outline/symbol/lines for files too big to read whole. Any codedb-pro failure unblocks the natives for the rest of the session.";
/// Licensed-aware variant of the codedbpro note. Native `codedb` and
/// `read_file` are the default readers (ADR 0040) — do not send ordinary
/// file reads through mcp__codedbpro__read. Pro is extra search/batch.
/// Edits stay native: edit_file/write_file are /rewind-snapshotted.
const codedbpro_note_licensed = "The codedb-pro MCP server is connected and LICENSED — mcp__codedbpro__* tools (load once per session via load_tool_schemas, e.g. by query) are extra, not the default reader. READ with native codedb (context/around/callpath/list_dir/status) or read_file. Do not use mcp__codedbpro__read for ordinary files. SEARCH: codedb first; faster_search/meta_search only when codedb cannot answer (raw regex, fuzzy, non-indexed). KEEP EDITS on native edit_file/write_file — codedb-pro write tools are hidden because they bypass /rewind.";

const McpNote = struct { server: []const u8, note: []const u8 };
pub const mcp_notes = [_]McpNote{
.{
.server = "codedbpro",
.note = "The codedb-pro MCP server is connected (mcp__codedbpro__* tools). SEARCH ORDER: the native codedb tool is free and indexed — always try it first (context/around/callpath/list_dir/status); reach for mcp__codedbpro__faster_search or meta_search only when codedb can't answer (raw literal/regex content matches, fuzzy queries, non-indexed files) — codedb-pro is metered. Prefer mcp__codedbpro__read (mode=outline first, then symbol) over read_file for navigating large code files, and mcp__codedbpro__batch to run several independent reads/searches/edits in one round-trip. Keep edits on the native edit_file/write_file tools (/rewind-tracked; the cwd and explicit-external-target rules above apply). These tools are accelerators, not requirements: if an mcp__codedbpro__ call fails, fall back to read_file/codedb/bash and continue.",
.note = "The codedb-pro MCP server is connected (mcp__codedbpro__* tools). READ with native codedb (context/around/callpath/list_dir/status) or read_file — do not use mcp__codedbpro__read for ordinary files. SEARCH ORDER: codedb first; reach for mcp__codedbpro__faster_search or meta_search only when codedb can't answer (raw literal/regex, fuzzy, non-indexed) — codedb-pro is metered. Keep edits on native edit_file/write_file (/rewind-tracked). These tools are accelerators, not requirements: if an mcp__codedbpro__ call fails, fall back to read_file/codedb/bash and continue.",
},
.{
.server = "muonry",
.note = "The muonry MCP server is connected (mcp__muonry__* tools). SEARCH ORDER: the native codedb tool is free and indexed — always try it first (context/around/callpath/list_dir/status); use mcp__muonry__search or faster_search only when codedb can't answer (raw literal/regex content matches, non-code or non-indexed files) — muonry is metered. Prefer mcp__muonry__read (mode=outline first, then symbol) over read_file for navigating large code files, and mcp__muonry__batch to run several independent reads/searches/edits in one round-trip. Keep edits on the native edit_file/write_file tools (/rewind-tracked; the cwd and explicit-external-target rules above apply). These tools are accelerators, not requirements: if an mcp__muonry__ call fails, fall back to read_file/codedb/bash and continue.",
.note = "The muonry MCP server is connected (mcp__muonry__* tools). READ with native codedb or read_file — do not use mcp__muonry__read for ordinary files. SEARCH ORDER: codedb first; muonry search/faster_search only when codedb can't answer. Keep edits on native edit_file/write_file (/rewind-tracked). Accelerators, not requirements: if a muonry call fails, fall back to read_file/codedb/bash and continue.",
},
};

Expand Down Expand Up @@ -393,6 +391,8 @@ test "codedbproNote: licensed flips codedbpro to the lean-in note" {
try std.testing.expect(std.mem.indexOf(u8, codedbpro_note_licensed, "mcp__codedbpro__*") != null);
try std.testing.expect(std.mem.indexOf(u8, codedbpro_note_licensed, "load_tool_schemas") != null);
try std.testing.expect(std.mem.indexOf(u8, codedbpro_note_licensed, "/rewind") != null);
try std.testing.expect(std.mem.indexOf(u8, codedbpro_note_licensed, "Do not use mcp__codedbpro__read") != null);
try std.testing.expect(std.mem.indexOf(u8, codedbpro_note_licensed, "READ with native codedb") != null);
}

test "skillIndex: registry lookup" {
Expand Down
2 changes: 1 addition & 1 deletion src/tool_gates.zig
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ pub fn withAvailable(comptime Spec: type, arena: Allocator, base: []const Spec,
}

/// One catalog-assembly pass: optional tools, then #330/#lean, then the
/// licensed surface filter (hide read_file/codedb when codedb-pro is in charge).
/// licensed surface filter (ADR 0040: do not hide codedb or read_file).
pub fn assembleRoot(comptime Spec: type, arena: Allocator, base: []const Spec, optional: []const Spec) ![]const Spec {
const no_local_tools = @import("no_local_tools.zig");
const tool_surface = @import("tool_surface.zig");
Expand Down
Loading