Skip to content

feat: memory.quota — your memories against your plan cap on REST, MCP and the CLI - #713

Merged
dash0-dev[bot] merged 14 commits into
mainfrom
feat/memory-quota-surface
Oct 21, 2026
Merged

dash0-dev[bot] merged 14 commits into
mainfrom
feat/memory-quota-surface

Conversation

@dash0-dev

@dash0-dev dash0-dev Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Follows #712 (merged as ec35d81; this branch now targets main, synced with a merge commit). Plan usage moves off the dashboard's private server action into LoreKit's public surface, as one operation on three faces: GET /memories/quota on REST, the memory.quota MCP tool, and lorekit quota on the CLI. All three answer from the existing actor-guarded lorekit_memory_count RPC (00047). No migration.

The contract

{ "plan": "free", "personal": { "count": 3318, "limit": 5000, "remaining": 1682 }, "org_count": 982, "total": 4300 }

personal is the pair the cap trigger enforces. The RPC's count adds personal and org-owned memories, but its limit is the personal cap, while the trigger (00018) counts personal rows against the personal limit and org rows against each org's own limit. A remaining computed from the combined count would therefore be wrong for both. So org_count sits beside the personal pair and is never subtracted. The shaping lives once, in the pure memoryQuotaFromRow (packages/mcp-core/src/limits/memory-quota.ts, mirrored to _shared/limits/ and drift-checked).

Changes

  • REST: GET /memories/quota (memories/handlers/quota.ts), registered before /:id. It's an OpenAPI path with a MemoryQuotaResponse schema, mapped to memory.quota in both rest-tool-name.ts copies. Service-role callers get 403, because they have no user and no cap.
  • MCP: memory.quota is in the catalog (read, READ_ONLY, token-or-jwt) and handled by toolQuota. It's in READ_TOOLS in both permission mirrors and has a check in smoke-mcp-tools.mjs. It is localMcpExempt, because the offline store has no plan and no cap. It returns counts only, never a scope or key name, so a scope-restricted key may call it (the same reasoning as /usage); a key narrowed to personal or selected orgs gets its org half narrowed (see Tenancy below).
  • CLI: lorekit quota prints the personal pair, the org count and the total, with a warning at the cap and under 10 % headroom. It is remote-only and exits 1 when the remote can't answer, so a script can gate a bulk write on it. It has help, completions, a smoke case and test/quota.test.mjs.
  • Web:
    • The TopBar badge reads total from the browser with React Query's signal.
    • Settings → Plan reads it on the server and now shows the personal pair. Before, it showed the combined count against the personal limit, so accounts with org memories saw a bar fuller than their cap.
    • Removed: the stopgap /api/plan/usage route from perf(web): run the /lore list, plan-usage and invites reads in parallel #712 and the direct-RPC getPlanUsage server action.
  • Docs:
    • Contributor docs: docs/api-tokens.md (quota in "What a scoped token sees"), docs/limits.md (reading your headroom), docs/cli.md, docs/key-files.md, and docs/decisions.md, which explains why this isn't a breach of "Dashboard analytics reads stay REST-only": it's the caller's own headroom against the cap that refuses memory.write, which an agent can act on.
    • User-facing: remote.mdx (check headroom before a migration).
    • llms.txt regenerated; its template now names only the account-wide sweeps as refused for a scoped token, and says how quota answers one.

Review follow-ups in this PR

  • MemoryExpandButton no longer claims the badge shows the same figure as Settings → Plan: the badge is total, the page the personal pair.
  • quota is listed in docs/mcp-tools.md (annotations table + its own section), packages/cli/README.md, the memories route table and the root CLAUDE.md command list.
  • Settings → Plan logs a failed quota read (lorekit.plan.quota_failed) instead of dropping it silently; it still renders the skeleton.
  • app-route.ts no longer says plan usage is migration backlog (carried over from perf(web): run the /lore list, plan-usage and invites reads in parallel #712's review).
  • The OpenAPI /memories/quota description states the token narrowing.

Tenancy

lorekit_memory_count counts every org the token's owner belongs to, so both surfaces now pass the calling key to quotaForKey (pure, in the mirrored memory-quota.ts). For a key narrowed to personal or selected orgs it recounts org_count over effectiveOrgIds and recomputes total: 0 under personal, only the org_ids the owner still belongs to under selected, never more than the owner's own figure. JWT callers and unrestricted keys are unchanged and trigger no extra read. personal is never narrowed, by tenancy or scope allowlist, because the cap is account-wide and a narrowed count would overstate remaining. The org recount is countActiveOrgMemories (_shared/limits/org-memory-count.ts), one head-only count with the same filters as the RPC. Behaviour is tested in memory-quota.spec.ts; tenant-scope-usage.spec.ts pins both surfaces to the key.

Not in this PR

Verification

  • Generators: gen-surfaces.mjs --check and sync-edge-schemas.mjs --check are in sync.
  • Unit tests:
    • vitest schemas: 318 pass.
    • mcp-core: 2,018 pass, including 7 quotaForKey cases and 3 tenancy source guards.
    • web: 1,695 pass (no web change since).
  • CLI: surface parity, completions, cli-smoke, cli and the new quota.test.mjs (91 tests) all pass.
  • Typecheck: deno-check-functions.mjs reports 0 errors in every edge function. tsc on packages/web is clean.
  • Lint: eslint on the changed files: 0 errors.
  • Not run in the sandbox: the hosted MCP smoke, the CLI remote lane, Integration smoke, Storybook and e2e. CI runs these.

CI is green at f76e8f2: Integration smoke, edge typecheck, Typecheck/Test/Lint, Storybook, Playwright e2e and the Vercel preview. The tenancy narrowing itself is not exercised end to end: no smoke lane mints a scoped key.

mthines and others added 4 commits October 20, 2026 18:09
The three reads were Next.js server actions, which the app router runs one
at a time per client and which cannot take an AbortSignal. On /lore they
queued (plan usage -> pending invites -> memory list) on every load and
every window focus, so the TopBar activity bar stayed on long after the
visible panels had loaded.

- useMemories calls POST /memories/list from the browser with React Query's
  signal (body building moved unchanged into the pure lib/memory-list.ts)
- useMemoryTotal and usePendingInvitesForMe read new GET route handlers
  (/api/plan/usage, /api/invites/pending) that wrap the existing server
  functions

Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
…r-action-reads rule

- parallel-reads.spec.ts: useMemories, useMemoryTotal and
  usePendingInvitesForMe issue ordinary requests with React Query's signal
  and never call a server action (the action modules are mocked to throw)
- read-routes.spec.ts: the two GET route handlers keep the wrapped
  functions' contract (null / [] when signed out) and are never cached
- memory-list.spec.ts pins the POST /memories/list body
- packages/web/CLAUDE.md, docs/decisions.md, CLAUDE.md: a client read is
  never a server action; docs/key-files.md and docs/storybook.md updated
- LorePage story now renders the populated list (baseline needs refresh)

Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
… and the CLI

GET /memories/quota, the memory.quota MCP tool and `lorekit quota` all answer
{ plan, personal: { count, limit, remaining }, org_count, total } from the
actor-guarded lorekit_memory_count RPC. personal is the pair the cap trigger
enforces; org-owned memories count against each org's own cap, so they sit
beside it instead of being subtracted (the RPC's combined count measured
against the personal limit made 'remaining' wrong for both).

The dashboard reads it over REST too: the TopBar badge (total) from the
browser with React Query's signal, Settings > Plan (personal pair) on the
server. The interim /api/plan/usage route and the direct-RPC getPlanUsage
server action are removed.

Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
…uards; cover lorekit quota

Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
@dash0-dev

dash0-dev Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ No issues found

Adds memory.quota on REST, MCP and the CLI with org counts narrowed to a token's tenancy; no code change since f76e8f2, and the edited description's Tenancy section now matches quotaForKey.

Progress: open review threads 2 → 1 → 1 → 0 → 0 across the last 5 reviews

Review details
Gate Details
Description vs. code ✅ The description matches what the diff does.
Prior review feedback ✅ Earlier review comments are resolved.
Documentation ✅ The change is documented well enough to follow.
Self-review signals ✅ No debug logs, leftover TODOs, or unreviewed stubs.
Code review ✅ The multi-lens review found no blocking issues.
Run Details
Mode incremental · 0 lines in delta · depth checkout · head unchanged since the last review, gates and threads only
Reach 31 changed exports · 29 dependent files · 0 checked · 29 not checked · 12 changed files imported by 30 files, not traced
MCP_TOOL_NAMES — 5 consumer files · 0 checked
LOCAL_MCP_EXEMPT — 3 consumer files · 0 checked
READ_TOOLS — 3 consumer files · 0 checked
MCP_TOOL_DEFS — 2 consumer files · 0 checked
MCP_TOOLS — 2 consumer files · 0 checked
+26 more
supabase/functions/_shared/telemetry/usage-stats.ts — imported by 8 files
packages/cli/src/store/remote.mjs — imported by 7 files
packages/cli/src/shared/completions.mjs — imported by 4 files
packages/web/src/components/settings/PlanPeakUsage.tsx — imported by 3 files
supabase/functions/_shared/schemas/tool-catalog.ts — imported by 3 files
+7 more
Found produced 0 → posted inline 0 · cleared 0 · carried forward 0 · deferred 0 · below-bar 0
Verified PR body edited 09:21 UTC after the last run: Tenancy section checked against quotaForKey, its 7 spec cases, 3 source guards, OpenAPI text
Memories skipped (zero-delta fast path)

Nothing to report — standards (skipped), measurability (skipped), integrations (skipped), severity, 0 files skipped.

pr-reviewer · commit f76e8f2 · no code changes since f76e8f2, gate checks only · how these findings are produced · updated Oct 21, 2026 9:26am UTC

Comment thread packages/web/src/lib/queries/plan.ts Outdated
Comment thread docs/cli.md
#712 (this PR's base) was squash-merged as ec35d81, and #711 landed beside
it. Resolved as main plus this PR's own delta (8f08fd3..01cf83a) replayed
on top, so the diff against main is exactly the memory.quota change.
@dash0-dev
dash0-dev Bot changed the base branch from perf/web-parallel-explorer-reads to main October 21, 2026 07:51
mthines and others added 5 commits October 21, 2026 08:08
… plan page

Addresses the pr-reviewer comment: #713 (comment)

Refs: #713
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
Addresses the pr-reviewer comment: #713 (comment)

Refs: #713
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
Addresses the pr-reviewer comment: #713 (comment)

Refs: #713
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
…is backlog

Addresses the pr-reviewer comment: #712 (comment)

Refs: #713
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

The dashboard preview for this PR — redeployed on each push that changes the web app.

Project Deployment Actions Updated
lorekit Ready Ready Preview Oct 21, 2026 9:08am UTC

Preview always points at this PR's latest commit; Deployment is the immutable build of f76e8f2. · Comment /web-preview to force a redeploy. · Workflow logs

Comment thread supabase/functions/memories/handlers/quota.ts
mthines and others added 2 commits October 21, 2026 08:43
lorekit_memory_count counts every org the token owner belongs to, so a
personal-only or selected-orgs key could read how many memories its
owner's other orgs hold. GET /memories/quota and memory.quota now pass
the calling key to quotaForKey, which recounts org_count over
effectiveOrgIds (0 under personal, only org_ids under selected) and
recomputes total. The personal pair is unchanged: the cap is
account-wide.

Refs: #713 (comment)
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
docs/api-tokens.md gains a quota row in "What a scoped token sees". The
llms.txt template no longer claims every operation without a scope is
refused for a scoped token: only the account-wide sweeps are, and quota
is answered with its org half narrowed. The catalog `returns`, the
response schema, and the MCP, CLI and limits docs say the same; llms.txt
and the edge schema mirror are regenerated.

Refs: #713 (comment)
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
Comment thread packages/schemas/src/openapi/spec.ts
mthines and others added 2 commits October 21, 2026 09:03
…rowing

Addresses the pr-reviewer comment: #713 (comment)

Refs: #713
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
@dash0-dev
dash0-dev Bot marked this pull request as ready for review October 21, 2026 09:28
@dash0-dev
dash0-dev Bot requested a review from mthines as a code owner October 21, 2026 09:28
@dash0-dev
dash0-dev Bot merged commit c4ea7ab into main Oct 21, 2026
19 checks passed
@lorekitbot lorekitbot Bot mentioned this pull request Oct 21, 2026
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.

1 participant