Repository navigation
Add a usage/event ledger for per-namespace metering, audit, and on-chain attribution #10
Description
Context
The hosted Worker has zero metering or audit. No usage table exists in D1 (migrations 0001–0006 cover accounts, namespaces, growth loops, schedules — none record per-action usage). Authed users are hardcoded quota: { ... unlimited: true } (apps/api/src/worker.ts:1503) and the only counter is the 1/day demo-IP quota (consumeDemoQuota, worker.ts:4296; table contextmem_demo_limits). The roadmap calls this ledger "the shared spine feeding billing, observability, AND on-chain attribution — build it first."
Goal / user story
As the platform owner, I want every billable/auditable action (extract, recall, remember, mcp_read, ai_query, chat) recorded as a durable per-namespace event so I can meter usage, power an observability dashboard, enforce quotas, and later anchor on-chain attribution receipts — built once.
Acceptance criteria
- New migration
0007_usage_events.sqlcreatescontextmem_usage_events(id, ts, owner_id, namespace, action with a CHECK on the enum, units INTEGER, request_id, source, metadata_json) + indexes on(namespace, ts)and(owner_id, ts). - A single
recordUsage(env, { ownerId, namespace, action, units, requestId, source, metadata })helper inserts one row, never throws (try/catch +ctx.waitUntil), and is unit-tested. - Events are emitted from the hosted handlers: extraction/build (
createNamespaceBuildJob), recall (/api/memwal/recall,worker.ts:631), chat (memwalChat,worker.ts:634/3498), ai-query (aiQueryRun,worker.ts:1349), hosted MCP reads (/mcp,isMcpPathworker.ts:800), and demo extractions. - Each event carries a stable
request_idfor idempotency so retries don't double-count. -
GET /api/usage?namespace=&from=&to=returns aggregated counts/units per action (owner-scoped) using the index, not a full-table scan. - No request bodies or secrets are stored: a test asserts
Authorization/delegate keys never leak intometadata_json.
Implementation notes
Reuse the D1 upsert pattern from consumeDemoQuota (worker.ts:4304). Wrap every emit in ctx.waitUntil(recordUsage(...)) so logging never blocks responses. Define the action union in a shared module so packages/mcp tool calls can emit the same events. Keep the table append-only; aggregate with SELECT action, COUNT(*), SUM(units) ... GROUP BY action. A daily rollup table can come later if volume grows — not required now. No wrangler.jsonc change (uses the existing CONTEXTMEM_DB binding).
Sui Overflow angle
This ledger is the precondition for on-chain attribution receipts: each metered extract/recall/remember becomes the exact payload a Talus/Nexus node anchors on Sui (caller, namespace, action, artifactDigest, ts). Building it now turns the later receipt work into a thin write-through over existing rows rather than a from-scratch capture layer during the demo crunch.
Dependencies
Owner-scoping of /api/usage depends on the "hosted accounts + owner-scoped authorization" issue (otherwise ownerId is spoofable). The on-chain attribution-receipt issue consumes this ledger.
Part of the ContextMEM roadmap (#4) • Sui Overflow build.
Activity
harrymove-ctrl commentedon Jun 19, 2026
Implementation Plan
Approach
Add one append-only D1 table (
contextmem_usage_events) plus a singlerecordUsage(env, {...})helper that never throws and is always wrapped inctx.waitUntil(...), modeled on the existingconsumeDemoQuotaupsert pattern (apps/api/src/worker.ts:4296). Emit one event from each hosted handler (extract/build, recall, chat, ai-query, hosted MCP reads, demo extractions), keyed by a stable per-operationrequest_idthat anINSERT … ON CONFLICT(request_id, action) DO NOTHINGuses for idempotency. ExposeGET /api/usagethat aggregates with aGROUP BY actionquery served off the(owner_id, ts)/(namespace, ts)indexes. Theactionunion lives in@contextmem/coresopackages/mcpcan reference the same enum; the D1-boundrecordUsagehelper lives in the Worker (D1 only exists there). This is intentionally built once as the shared spine for billing, observability, and the later on-chain attribution receipt (roadmap §6 "build it first").Prerequisites & dependencies
CONTEXTMEM_DBD1 binding (wrangler.jsonc:22-28); the migration auto-applies viamigrations_dir. Nowrangler.jsoncedit.owner_idon/api/usagedepends on the roadmap's "Real hosted accounts + owner-scoped authorization" item (§6 P1). Until that lands,owner_idis derived fromreadHostedRunAuth→hostedOwnerId(accountId)(worker.ts:1461,1512) /demoOwnerId(worker.ts:4317), which is spoofable via thex-memwal-account-idheader. Ship the ledger now; the/api/usagescoping tightens automatically when accounts land. Call this out in the PR.Step-by-step
0007_usage_events.sql(new,apps/api/migrations/0007_usage_events.sql). Follow the style of0001_account_gate.sql(TEXT timestamps,IF NOT EXISTS):(request_id, action)index is what makesON CONFLICT DO NOTHINGidempotent on retries.packages/core(newpackages/core/src/usage.ts; addexport * from "./usage.js";topackages/core/src/index.ts:13):recordUsage, andpackages/mcpall reference.recordUsage+aggregateUsagehelper (newapps/api/src/usage.ts, imported byworker.ts).recordUsagemust mirror theconsumeDemoQuotashape (worker.ts:4296-4311) but be fail-safe:sanitizeUsageMetadata()that drops any key matching/authorization|bearer|token|secret|private[-_]?key|delegate|cookie|x-memwal/iand caps value length — this is what the leak test asserts against (AC Build HarborClient: reserve→sign→finalize bucket, multipart upload, status poll, download #6). AddaggregateUsage(env, ownerId, namespace?, from?, to?)runningSELECT action, COUNT(*) AS count, SUM(units) AS units FROM contextmem_usage_events WHERE owner_id = ? [AND namespace = ?] [AND ts >= ?] [AND ts < ?] GROUP BY action— owner_id-first so the(owner_id, ts)index is used (no full scan, AC Spike: run @mysten/seal encrypt+decrypt inside a Cloudflare Worker #5).extractfrom build/extract increateNamespaceBuildJob(worker.ts:1901-1941). It already hasinput.ownerId,namespace, the stablejobId(worker.ts:1907), andctx. After the insert + queue send, addif (ctx.waitUntil) ctx.waitUntil(recordUsage(env, { ownerId: input.ownerId, namespace, action: 'extract', units: sources.length, requestId: jobId, source: input.ownerId.startsWith('demo:') ? 'demo' : 'web', metadata: { buildKind, sourceCount: sources.length } })).jobIdasrequest_idmeans queue retries / re-invocation don't double-count (AC docs: add product roadmap (Harbor storage + Talus on-chain direction) #4). This single site covers bothcreateExtraction/createNamespaceBuildand demo extractions (demo flows throughcreateExtractionJob → createNamespaceBuildJobwithdemoOwnerId, AC bullet "demo extractions").recallfrommemwalRecall(worker.ts:3408). Threadctxinto the handler (update call siteworker.ts:632tomemwalRecall(request, env, ctx)and the signature). Resolve owner:readHostedRunAuth(request)?.ownerId ?? demoOwnerId(request). On a returned response,ctx.waitUntil(recordUsage(env, { ownerId, namespace, action: 'recall', requestId: usageRequestId(request), source: 'api', metadata: { source: <'walrus-memory'|'facts'> } })). Add ausageRequestId(request)helper (new inusage.ts): honor an inboundIdempotency-Key/x-request-idheader, elsecrypto.randomUUID().chatfrommemwalChat(worker.ts:3498). Threadctx(call siteworker.ts:635→memwalChat(request, env, ctx)). Owner resolution as in step 5. Emitaction: 'chat',metadata: { source, recallHits: recallHits.length }(counts only, never message text).ai_queryfromaiQueryRun(worker.ts:1349). Threadctx(call siteworker.ts:652→aiQueryRun(request, env, hostedRunRoute.runId, ctx)). It already resolvesjob.ownerIdand the namespace; userequestId: runId(stable). Emit on the success path,metadata: { model?: env.OPENAI_MODEL }.mcp_readin the MCP dispatch block (worker.ts:772-794). AfterauthorizeNamespacesucceeds (auth.ok),ctx.waitUntil(recordUsage(env, { ownerId: auth.summary.owner_id ?? demoOwnerId(request), namespace, action: 'mcp_read', requestId: usageRequestId(request), source: 'mcp', metadata: { method: request.method } })). Owner = the namespace owner fromauth.summary(the read happens against their namespace). Gateway (namespace-less) MCP calls: emit withnamespace: null, owner =demoOwnerId/caller.remember(enum-complete, optional emit): wire it where the extraction job performs the MemWal index write insideprocessExtractionJobif reachable in this pass; otherwise the enum value is reserved for the on-chain-receipt issue. Not required by the AC emit list — keep it in the CHECK so no migration is needed later.GET /api/usage?namespace=&from=&to=route. Add torouteWorkerRequestnear the other GET/api/*routes (e.g. afterworker.ts:592/api/me): requirereadHostedRunAuth(request)(401 if absent — owner-scoped), thenreturn json(await aggregateUsage(env, auth.ownerId, namespace, from, to)). Validatefrom/toas ISO dates; clamp range. Returns{ namespace, from, to, totals: [{ action, count, units }], total: {count, units} }.Idempotency-Key/x-request-idheader, add it to theaccess-control-allow-headerslist (worker.ts:5307).New / changed files
apps/api/migrations/0007_usage_events.sql(new)packages/core/src/usage.ts(new) —USAGE_ACTIONS,UsageAction,UsageSourcepackages/core/src/index.ts(edit) — add barrel exportapps/api/src/usage.ts(new) —recordUsage,aggregateUsage,sanitizeUsageMetadata,usageRequestIdapps/api/src/worker.ts(edit) — import helpers; emit atcreateNamespaceBuildJob(1901),memwalRecall(3408),memwalChat(3498),aiQueryRun(1349), MCP dispatch (772); threadctxinto recall/chat/ai-query call sites (632/635/652); addGET /api/usageroute; CORS header (5307)apps/api/src/usage.test.ts(new) and/or additions toapps/api/src/worker.test.tsapps/api/src/worker.test.ts(edit) — extendMemoryD1Database(add ausageEventsarray + handle theINSERT … contextmem_usage_eventsand theGROUP BY actionSELECT, mirroring the existing SQL-string matching atworker.test.ts:845-935)Test plan
recordUsage): inserts exactly one row; calling twice with the same(request_id, action)yields one row (idempotency, AC Memory knowledge graph: 3D neural constellation + grounded entity panel #2 + docs: add product roadmap (Harbor storage + Talus on-chain direction) #4); a throwing/undefinedD1 stub does NOT reject the promise (never-throws, AC Memory knowledge graph: 3D neural constellation + grounded entity panel #2).sanitizeUsageMetadata/ leak test, AC Build HarborClient: reserve→sign→finalize bucket, multipart upload, status poll, download #6): pass metadata containingAuthorization,x-memwal-authorization, a 64-char hex delegate,Bearer …→ assert the persistedmetadata_jsoncontains none of them. Also a handler-level test: fire a recall/chat with delegate headers set, then read back the usage row and assert no header/secret substring appears./api/namespaces/build, then assert oneextractrow withrequest_id === jobId; POST/api/memwal/recalland/api/memwal/chat→ onerecall/chatrow each; a hosted MCPGET/POSTagainst an authorized namespace → onemcp_readrow;aiQueryRun→ oneai_queryrow./api/usage, AC Spike: run @mysten/seal encrypt+decrypt inside a Cloudflare Worker #5): seed several events across two namespaces/actions; assertGET /api/usage?namespace=…&from=…&to=returns correct per-actioncount/units, is owner-scoped (a different owner sees nothing / 401 without auth), and respects thefrom/towindow.wrangler d1 migrations apply contextmem; deploy the Worker (wrangler deploy— note: CI does not ship the Worker per repo deploy notes); run one real hosted build + one chat againstcontextmem-backend.petlofi.workers.dev;wrangler d1 execute contextmem --command "SELECT action, COUNT(*) FROM contextmem_usage_events GROUP BY action"shows the rows;GET /api/usagereturns matching aggregates.Risks & gotchas
ctx.waitUntilruns after the response; in unit testswaitUntilmay be undefined —recordUsagemust beawaited in the no-ctxtest path (the harness passesctx = {}), or assert after awaiting the returned promise. Mirror thecreateNamespaceBuildJobpattern (worker.ts:1934-1937).ON CONFLICT DO NOTHING+ indexes keep it cheap. A daily rollup table is explicitly out of scope (issue note)./api/usagescoping is only as strong as identity — header-derivedowner_idis spoofable until real accounts land (see Prerequisites). Do not advertise it as billing-grade yet.MemoryD1Database), not real SQLite — it will silently returnnull/empty for any query it doesn't pattern-match, so the new INSERT and GROUP BY branches MUST be added or integration assertions will pass vacuously. Consider a focused unit test against a realbetter-sqlite3/bun:sqlitefor the aggregation SQL if higher fidelity is wanted.metadata_jsonis the leak surface — only ever pass computed scalars (counts, durations, model name, status, source); never spreadrequest.headers, the body, or creds. The sanitizer is defense-in-depth, not the primary guard.USAGE_ACTIONSin core must stay in lockstep; a value present in one but not the other fails inserts. Keep both in this PR and reference the core constant in code.remembernot yet emitted — leaving it in the enum is intentional (no future migration); just don't claim it's wired unless step 9 lands.Acceptance-criteria mapping
0007_usage_events.sqlw/ CHECK enum + both indexes → Step 1.recordUsage(...)helper, never throws,ctx.waitUntil, unit-tested → Steps 3, 4-8 (all emits wrapped inwaitUntil), Test plan (unit).request_idfor idempotency → Step 1 UNIQUE(request_id, action)+ Step 3ON CONFLICT DO NOTHING+ Steps 4/7 use natural ids (jobId/runId), Steps 5/6/8 useusageRequestId; Test plan idempotency case.GET /api/usageaggregates per action via index, owner-scoped → Steps 3 (aggregateUsage, owner_id-first), 10; Test plan/api/usagecase.metadata_json; test asserts no leak → Step 3sanitizeUsageMetadata+ Steps 5-8 passing only scalars; Test plan leak test.Effort: M — ~2 person-days. Migration + two small modules are trivial; the bulk is threading
ctxinto three existing handlers and extending the bespokeMemoryD1Databasetest mock to cover the new INSERT/GROUP BY so the integration assertions are real.Implementation plan generated for execution (planning phase). Part of the roadmap (#4).