Skip to content

fix(mcp): scope Lunora by-id writes to their table - #331

Open
cameronapak wants to merge 4 commits into
mainfrom
fix/mcp-lunora-table-scoped-writes
Open

fix(mcp): scope Lunora by-id writes to their table#331
cameronapak wants to merge 4 commits into
mainfrom
fix/mcp-lunora-table-scoped-writes

Conversation

@cameronapak

@cameronapak cameronapak commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Summary

Every MCP write whose plan contained a patch or delete failed with -32603; insert-only writes succeeded and reads were fine. An agent could add bullets but could never check one off, edit it, move it, or delete it. Lunora sync only.

Root cause: ctx.db.asId(table, id) is a compile-time parse boundary. It returns a phantom-branded Id<T> whose table name erases before the call reaches the store, and the typed by-id patch/delete have no expectedTable parameter to carry it — so the id resolved via UNION ALL across all five shard tables and tripped Workerd SQLite's compound-SELECT limit.

The old comment named that exact constraint and then concluded asId compensated for it. It doesn't.

Changes

  • lunora/mcp.ts — route the three by-id writes through the per-table accessor (ctx.db.nodes, ctx.db.dailyIndex). bindTableFacade binds each accessor with its table name forwarded as expectedTable, so they're scoped by construction rather than by remembering an argument. Comment rewritten to say what asId actually does.
  • worker/lunora-mcp-bridge.test.ts — regression tests that model the unscoped by-id path as the throw it is. The fake asserts the invariant ("an id-addressed write names its table at runtime"), not one spelling of it: it accepts both the bound accessor and the explicit third argument lunora/mutators.ts uses, and throws only when neither carries a table. Covers commitPlan (nodes) and claimDailyMapping (dailyIndex).
  • lunora/mutators.ts + lunora/mcp.ts — cross-reference comments on the two commitPlan twins. Same plan, same bucket order, two bodies, nothing keeping them in lockstep — which is how the MCP copy shipped unscoped while the browser path stayed healthy. Comment only; no shared-planner extraction (the two ctx types make it awkward, and that's a wider change than a fix PR).
  • AGENTS.md — the learned fact said "mutators must pass expectedTable", which read as mutator-specific and left asId looking sufficient. Now covers both correct spellings and states plainly that asId scopes nothing.
  • Changeset (patch).

Inserts already named their table (ctx.db.insert("nodes", …)) and were never affected.

Flow

MCP tool call
  → outline-ops planner  → OutlinePlan { deletes, patches, inserts }
  → commitPlan
      deletes → ctx.db.nodes.delete(id)          ← was ctx.db.delete(asId(...))
      patches → ctx.db.nodes.patch(id, fields)   ← was ctx.db.patch(asId(...))
      inserts → ctx.db.insert("nodes", …)        ← unchanged, already scoped
  → bindTableFacade forwards "nodes" as expectedTable
  → scoped id lookup, no UNION ALL

Breaking

None. Restores intended behavior.

Test plan

  • New tests fail without the fix — verified by reverting commitPlan AND claimDailyMapping to the unscoped by-id calls and re-running: 3 of 7 fail. With the fix: 7 pass.
  • bun run test — 1018 pass, 0 fail
  • typecheck, typecheck:worker, typecheck:test, lint, fmt:check — all green
  • Not verified against a live shard. Everything above runs against a fake; the real UNION ALL failure can only be exercised on a Lunora account. Worth one update_node + one delete_node through MCP after deploy.

Notes for review

Two judgment calls worth a look:

1. commitPlan is now exported. The file header says "full shard RPC stays integration-only," and I kept that — the fake is a call recorder, not a store, and the test asserts routing, which is where the bug lived. But it does widen the module surface for a test seam. Say the word if you'd rather it stayed private.

2. A cast on the patch payload. The generated Insert_nodes drops .nullable() — the schema declares parentId: v.string().nullable() but codegen emits parentId: string, so a legitimate null patch (moving a node to the top level) doesn't satisfy Partial<Insert_nodes>. The write is fine; the generated type is too narrow. There's a test covering the null case so the cast can't quietly hide a real break. This looks like a lunorash codegen defect and may be worth reporting upstream alongside the asId ergonomics.

3. Upstream ergonomics. bindTableFacade's own doc says the forwarded scope also prevents an IDOR — a branded id from another table resolving cross-table. Here the blast radius is bounded (rows are shard-scoped per user and assertOwner gates the call), so this was a crash, not a data-leak. But the general shape is worth raising: the typed by-id API silently drops information the looser one carries, so the disciplined-looking call is the broken one.

Fixes #330

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Restored outline-editing actions for AI agents connected via MCP, including to-do toggles, text edits, collapsing bullets, deleting/moving items, and adding items to the top of lists.
    • Fixed AI agent outline operations failing with internal errors.
    • Enabled “claim daily note” for accounts on the upgraded sync option.
  • Tests
    • Added regression tests ensuring outline updates use table-scoped writes and correctly preserve nullable parent relationships.
    • Added tests verifying daily-note claiming updates use the daily index path.

Every MCP write whose plan contained a patch or delete failed with
`-32603`; insert-only writes succeeded. Reads were unaffected.

`ctx.db.asId(table, id)` is a compile-time parse boundary — it returns a
phantom-branded `Id<T>` whose table name erases before the call reaches
the store, and the typed by-id `patch`/`delete` have no `expectedTable`
parameter to carry it. The id then resolved via `UNION ALL` across all
five shard tables, tripping Workerd SQLite's compound-SELECT limit. The
prior comment named that constraint correctly and then concluded `asId`
compensated for it, which it does not.

Route the three by-id writes through the per-table accessor instead
(`ctx.db.nodes` / `ctx.db.dailyIndex`), which `bindTableFacade` binds
with its table name forwarded as `expectedTable` — scoped by
construction, so it cannot drift back. Inserts already named their table
and were never affected.

The existing unit coverage passed throughout because its fake store does
not model shard id resolution, so add a regression test that models the
by-id path as the throw it is.

Fixes #330

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@cameronapak, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 20 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4efc7e21-2796-494a-a4e6-a9eea7311c15

📥 Commits

Reviewing files that changed from the base of the PR and between 9487229 and 39dd12f.

📒 Files selected for processing (1)
  • worker/lunora-mcp-bridge.test.ts
📝 Walkthrough

Walkthrough

MCP outline mutations now use runtime table-scoped node accessors, and daily-note mapping updates use the scoped daily-index accessor. Tests verify routing, operation order, nullable patches, and both mapping paths. Workspace guidance and a patch changeset document the correction.

Changes

MCP shard-scoped mutations

Layer / File(s) Summary
Route mutations through scoped accessors
lunora/mcp.ts, lunora/mutators.ts, AGENTS.md
commitPlan uses scoped node accessors, claimDailyMapping uses the scoped dailyIndex accessor, nullable node patches use Partial<Insert_nodes>, and Lunora guidance documents runtime table scoping.
Verify scoped commit behavior
worker/lunora-mcp-bridge.test.ts
Tests reject unscoped by-id operations, verify node-scoped delete/patch/insert ordering, preserve parentId: null, and cover existing and missing daily-index mappings.
Document the patch release
.changeset/scoped-mcp-shard-writes.md
The changeset records a patch release for restored MCP outline mutations and daily-note claiming on upgraded sync accounts.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The code fixes the scoped by-id write failures in commitPlan and claimDailyMapping and adds regression coverage for #330.
Out of Scope Changes check ✅ Passed Changes are limited to the scoping fix, tests, docs, and changelog support with no unrelated additions.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main fix: scoping Lunora by-id writes to their table in MCP.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/mcp-lunora-table-scoped-writes

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@worker/lunora-mcp-bridge.test.ts`:
- Around line 61-63: Update the patch mock in the test to accept and record its
fields payload alongside id, operation, and table. Extend the assertions around
the `"n1"` patch call to verify the recorded fields equal `{ parentId: null }`,
ensuring the payload is propagated rather than only routing being tested.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1f254f66-87ab-4253-8bb0-bb576d391f0b

📥 Commits

Reviewing files that changed from the base of the PR and between 3fb6197 and 3cdceaa.

📒 Files selected for processing (4)
  • .changeset/scoped-mcp-shard-writes.md
  • AGENTS.md
  • lunora/mcp.ts
  • worker/lunora-mcp-bridge.test.ts

Comment thread worker/lunora-mcp-bridge.test.ts Outdated
The recording fake dropped `patch`'s second argument, so the null-field
test proved routing only — a cast that silently discarded
`parentId: null` would still have passed the very case it exists to
cover. Record the fields and assert them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — minor suggestions inline.

Reviewed changes

  • lunora/mcp.ts — three by-id writes (commitPlan deletes, commitPlan patches, claimDailyMapping) moved to the per-table accessor; commitPlan exported as a test seam.
  • worker/lunora-mcp-bridge.test.ts — new recordingCtx() fake plus two regression tests.
  • AGENTS.md — the learned-fact bullet rewritten to name both correct spellings and state that asId scopes nothing.
  • .changeset/scoped-mcp-shard-writes.md — patch bump.

I verified the diagnosis rather than taking it on faith: bindTableFacade really does forward its bound name as expectedTable (node_modules/@lunora/server/dist/packem_shared/bindOrm-Bp9hsM2q.mjs), and the nominal DatabaseWriter really does declare delete: <T>(id: Id<T>) => Promise<void> with no expectedTable parameter while the runtime writer has one — so the asymmetry the fix is built on is real, and asId is compile-time only. I also checked for residual unscoped writes: lunora/mcp.ts and lunora/mutators.ts are the only files with by-id patch/delete/get calls, and every mutators call site already passes the explicit table. bun test worker/lunora-mcp-bridge.test.ts → 4 pass.

ℹ️ Two copies of commitPlan, and nothing keeps them in lockstep

lunora/mutators.ts:122-142 is a near-identical twin of the commitPlan this PR fixes — same deletes → patches → inserts order, same nodeToInsertFields, same OutlinePlan. It was always correct, because MutatorCtx is loose enough to accept the third expectedTable argument, so it uses ctx.db.delete(id, "nodes") while the MCP copy needed the facade. That divergence in spelling is exactly why the MCP copy could drift and ship broken while the browser path stayed healthy, and the fix doesn't change that: the two are still independent bodies that must be edited together.

Worth a human call on scope. Extracting a shared planner-applier is awkward given the two ctx types, but a comment on each pointing at the other, or a note in AGENTS.md that outline-plan application exists twice, would at least make the coupling discoverable. Out of scope for a fix PR if you'd rather not widen it.

ℹ️ First runtime use of the table facade in this repo — the live-shard check is the only thing that can confirm it

Before this PR nothing in the repo called ctx.db.<table>.*. I chased the static evidence and it holds up: the facade is attached in lunora/_generated/shard.ts:887-893 inside the single buildCtx, which every dispatch path goes through — including the internal-mutation system RPC that worker/lunora-mcp-store.ts uses — and the instrumentation wrapper is a Proxy with only a get trap, so the property assignment lands and reads pass through. So ctx.db.nodes should exist at runtime.

But the unit test can't corroborate any of that: its fake supplies the accessor by construction, so an absent one would still pass. Your own test-plan box "not verified against a live shard" is still unchecked, and it's the only check that closes this. One update_node and one delete_node over MCP on a Lunora account after deploy would do it.

ℹ️ Nitpicks

See the two inline comments on worker/lunora-mcp-bridge.test.ts.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread worker/lunora-mcp-bridge.test.ts Outdated
Comment thread worker/lunora-mcp-bridge.test.ts Outdated
@pullfrog

pullfrog Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

Two review nitpicks from pullfrog on #331.

The fake threw for ANY by-id patch/delete, including the correct 3-arg
`ctx.db.patch(id, fields, "nodes")` form that `lunora/mutators.ts` uses
and that this branch's own AGENTS.md bullet blesses. That asserted a
spelling, not the invariant — a refactor to the explicit-arg form would
have failed a test while being correct. It now throws only when the
table argument is absent, which is the actual constraint, and records
the table for either correct form.

`claimDailyMapping` had no test at all, so the dailyIndex half of the
fix could regress silently. Two more cover it (patch on an existing
mapping, insert on a missing one), plus one guarding the guard.

Cross-reference comments on both `commitPlan` twins: same plan, same
bucket order, two bodies, and nothing keeps them in lockstep — which is
how the MCP copy shipped unscoped while the browser path stayed healthy.

Reverting `commitPlan` + `claimDailyMapping` to the unscoped by-id calls
fails 3 of 7; restored, 7 pass. Full suite 1018 pass, all gates green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pullfrog

pullfrog Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
lunora/mutators.ts (1)

122-150: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Consider collapsing the twin commitPlan implementations.

The comment correctly documents that this file's commitPlan and lunora/mcp.ts's commitPlan are hand-synced twins, and that their divergence is exactly what shipped the #330 bug. Documentation reduces but doesn't eliminate the recurrence risk — a future edit to one body without the other reintroduces the same class of bug. If feasible, consider factoring the shared bucket logic (deletes → patches → inserts) into one function parameterized by a write-adapter (delete/patch/insert primitives), so MutatorCtx and MutationCtx each supply their own adapter but the ordering/logic lives in one place.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lunora/mutators.ts` around lines 122 - 150, Consolidate the duplicate
commitPlan implementations in lunora/mutators.ts and lunora/mcp.ts by extracting
one shared helper for the deletes → patches → inserts bucket logic,
parameterized by delete, patch, and insert write operations. Update each
commitPlan to provide its context-specific adapter while preserving the existing
node/table handling and operation order.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@worker/lunora-mcp-bridge.test.ts`:
- Around line 144-155: Await the rejected-promise assertion in the test “an
unscoped by-id write is what the fake rejects — the bug, not a spelling” by
adding await before expect(byId.patch("n1", {})).rejects.toThrow(...). Keep the
subsequent awaited byId.patch call and call-record assertion unchanged.

---

Nitpick comments:
In `@lunora/mutators.ts`:
- Around line 122-150: Consolidate the duplicate commitPlan implementations in
lunora/mutators.ts and lunora/mcp.ts by extracting one shared helper for the
deletes → patches → inserts bucket logic, parameterized by delete, patch, and
insert write operations. Update each commitPlan to provide its context-specific
adapter while preserving the existing node/table handling and operation order.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 42ef3d35-e7e5-4d09-8cd6-86997f6be4f3

📥 Commits

Reviewing files that changed from the base of the PR and between 3cdceaa and 9487229.

📒 Files selected for processing (3)
  • lunora/mcp.ts
  • lunora/mutators.ts
  • worker/lunora-mcp-bridge.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • lunora/mcp.ts

Comment thread worker/lunora-mcp-bridge.test.ts
CodeRabbit's nit on #331. Every other `.rejects` in the repo is awaited
(9 call sites); this one wasn't.

Its stated failure mode does NOT reproduce here: bun 1.3.14 surfaces the
unawaited rejection either way — checked by flipping the expected message
to one that never appears, with and without the await, and with a minimal
repro that has nothing after it to yield. Both fail. So this is
consistency and cross-runner portability, not a vacuous test.

Full suite 1018 pass, all gates green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

Three commits since the prior pullfrog review (3cdceaa), all responding to that review's feedback. The production delta is comment-only; the substance is the test file.

  • The fake now models the invariant, not a spellingrecord() throws only when the table is absent, and writer(bound?) accepts either correct form: the bound accessor or the explicit third argument lunora/mutators.ts uses. A future refactor to the explicit-arg form no longer fails a test for being correct.
  • claimDailyMapping is covered — two tests through .handler: an existing mapping asserts a patch on dailyIndex plus its payload, a missing one asserts the insert path carries no by-id write to scope.
  • Payload assertions addedpatched records the fields alongside the route, so a cast that quietly dropped parentId: null would now fail instead of routing correctly.
  • A guard-the-guard test — asserts the fake still rejects an unscoped by-id write, so record() can't go inert and silently green the two tests that depend on it. 39dd12f awaits that .rejects assertion.
  • Cross-reference comments on the two commitPlan twinslunora/mcp.ts:66-67 and lunora/mutators.ts:122-129 each point at the other and say why the two bodies differ.

I checked the regression coverage empirically rather than taking the test plan on faith: reverting all three lunora/mcp.ts call sites to the unscoped by-id form fails exactly 3 of 7 (commitPlan ×2, claimDailyMapping ×1), and restoring them returns 7 pass — so these tests bite on the bug they exist to catch. I also confirmed .handler is the real registered handler rather than a shim (RegisteredFunction.handler: (context, args) => …, @lunora/server/dist/types.d.ts:417; the revert's stack trace runs through Lunora's own initLunora wrapper), and that lunora/mutators.ts genuinely uses the explicit third-arg form the new comment blesses (ctx.db.delete(id, "nodes") at :134, mctx.db.patch(id, fields, "savedQueries") at :695) — so the "two correct spellings" framing is accurate, not aspirational. typecheck:test and lint are green.

The one item still open is unchanged and not something these commits could close: the live-shard test-plan box. The fake supplies ctx.db.nodes/ctx.db.dailyIndex by construction, so no unit test here can confirm the accessor exists at runtime — one update_node and one delete_node over MCP on a Lunora account after deploy stays the only check that does.

Pullfrog  | View workflow run | Using Claude Opus𝕏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP: every mutating write fails with -32603 (update_node, delete_node, move_nodes, position:"first"); inserts and reads work

1 participant