Browse and Manage Memory

MemoryStore.browse is for administration. It filters, sorts, counts, and paginates records across namespaces and statuses, with no semantic ranking. MemoryStore.search is for agent recall. It searches one namespace and can rank by keyword or vectors.

B4.run has no public HTTP endpoint for browsing memory. The Inspector is a local development tool, and the candidate management endpoints only cover review. So a production admin UI is your own authenticated, authorized route. Let's build one.

Derive the tenant prefix on the serverCopy link to section: Derive the tenant prefix on the server

Authenticate before the request reaches B4.run's models and tools. Derive the tenant namespace prefix from the verified principal, and AND any narrower query with that server-owned prefix. Never trust a prefix, tenant, or user identifier from request JSON. Here is a handler that does this:

src/admin/memory.ts
import {
  BROWSE_MAX_LIMIT,
  BrowseQueryError,
  validateBrowseQuery,
  type BrowsePage,
  type BrowseQuery,
} from "@b4run/memory/browse"
import { serializeNamespace } from "@b4run/memory/namespace"
 
type BrowseStore = {
  browse(query: BrowseQuery): Promise<BrowsePage>
}
 
type Principal = { tenantId: string; roles: readonly string[] }
 
function badRequest(message: string): Response {
  return new Response(message, { status: 400 })
}
 
export function createMemoryAdminHandler(
  store: BrowseStore,
  authenticate: (request: Request) => Promise<Principal>,
) {
  return async (request: Request): Promise<Response> => {
    const principal = await authenticate(request)
    if (!principal.roles.includes("memory-admin")) return new Response("Forbidden", { status: 403 })
 
    let raw: unknown
    try {
      raw = await request.json()
    } catch {
      return badRequest("Invalid JSON")
    }
    if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
      return badRequest("Expected a JSON object")
    }
 
    const input = raw as Record<string, unknown>
    const tenantPrefix = `${serializeNamespace({ tenant: principal.tenantId })}|`
    if (typeof input.namespace === "string" && !input.namespace.startsWith(tenantPrefix)) {
      return new Response("Forbidden", { status: 403 })
    }
 
    const query = {
      ...input,
      namespacePrefix: tenantPrefix,
      limit: input.limit ?? 50,
    } as BrowseQuery
    try {
      validateBrowseQuery(query, { maxLimit: BROWSE_MAX_LIMIT })
      return Response.json(await store.browse(query))
    } catch (error) {
      if (error instanceof BrowseQueryError) return badRequest(error.message)
      throw error
    }
  }
}

The @b4run/memory/browse entry holds only shared query code (types, validation, ordering, the cursor codec, and range helpers), and it doesn't import node:sqlite. Keep the store itself on the server. Never send database credentials or unrestricted browse queries to client code.

Outer authentication is only the first check. Authorize the admin role, audit queries and mutations, rate-limit expensive listings, redact content where needed, and bind every request to the server-derived tenant prefix.

The handler assumes the collections use a tenant-first scope with at least one more dimension (for example, scope: ["tenant", "user"]), so tenant=<encoded>| is a delimiter-bounded prefix. If a route includes workspace or route, B4.run's canonical dimension order puts those first. In that case, define and test a different server-owned prefix. Searching for tenant= in the middle of a namespace string isn't safe.

FiltersCopy link to section: Filters

Top-level fields cover common cases:

FieldMeaning
namespacePrefixServer-owned prefix restriction
namespaceExact, case-sensitive namespace, ANDed with the prefix
statuscandidate, active, or superseded, as one value or a set
kindsemantic, episodic, procedural, or reflection, as one value or a set
sourceTyperun, user, tool, eval, or human
since, untilInclusive lower and exclusive upper event-time instants
nowExcludes rows at or past expiresAt
limit, offset, cursorPage controls

Normalized filters are ANDed together, with at most one predicate per field:

  • status and kind: in, notIn
  • content: contains, notContains, equals, notEquals, startsWith, endsWith
  • namespace: equals, startsWith
  • confidence: eq, neq, gt, gte, lt, lte, between
  • updatedAt: onDay, beforeDay, afterDay, betweenDays, using UTC calendar days.

Content filters are case-insensitive in both in-repo stores. SQLite's built-in lower() folds ASCII only, so non-ASCII content matching can differ from Postgres. For example, case variants containing É can match under Postgres case-folding rules but not under stock SQLite. Namespace filters stay case-sensitive and byte-exact.

Call validateBrowseQuery where untrusted input enters, even though the in-repo stores also validate. The memory API reference lists the limits, constants, and error codes. A BrowseQueryError is a client error, so don't retry it as if the store were down.

Top-level status: [] and kind: [] are valid and match nothing (“any of no values” is false). In the normalized filters array, empty in or notIn filter values are invalid, because a filter with no choices points to a bug in the UI or query builder. Map BrowseQueryError to HTTP 400 as the handler does. Unexpected store or infrastructure failures stay server errors.

SortingCopy link to section: Sorting

orderBy is an ordered list of { field, dir }. Sort fields come from a closed whitelist: updatedAt, createdAt, confidence, namespace, kind, and status. The direction is asc or desc. Store code maps those names to known SQL columns and appends id ASC, so untrusted text never becomes a SQL identifier.

The default is updatedAt DESC, id ASC. Don't add arbitrary column names by casting request JSON. If a UI needs a new sort, add it to the shared type, the validator, the resolution table, both stores, and the conformance tests together.

Pagination and a fixed clockCopy link to section: Pagination and a fixed clock

Use cursor for a stable forward walk. A continuation is opaque. It carries a query fingerprint plus the last ordered key. It proves nothing about authorization, and clients shouldn't parse it. If you change the filters, sort order, namespace restrictions, or now, the store rejects it.

Pick one fixed now before the first page and reuse it for the whole walk. Calling new Date().toISOString() on every request changes the query fingerprint and can change which rows count as expired:

ts
const now = new Date().toISOString()
let cursor: string | undefined
 
do {
  const page = await store.browse({
    namespacePrefix: tenantPrefix,
    status: ["active", "candidate"],
    orderBy: [{ field: "updatedAt", dir: "desc" }],
    limit: 100,
    now,
    ...(cursor ? { cursor } : {}),
  })
  await exportRecords(page.records)
  cursor = page.continuation ?? undefined
} while (cursor)

The stores return a continuation whenever a page is full. They don't fetch an extra row to check for more. So if the result count is an exact multiple of the limit, the last full page has a continuation and the walk ends with one empty page. That's a normal finish.

Offset still works for bounded jumps a person makes, but inserts above the seam can shift records between requests. A cursor uses keyset continuation, so inserts above the seam leave the next window alone.

Count and snapshot semanticsCopy link to section: Count and snapshot semantics

BrowsePage.total counts the whole matching set, not the current window or the rows left after a cursor. Both in-repo stores read the page records and the total in the same transaction snapshot, so concurrent writes can't produce a records/total pair that never existed together.

The snapshot covers one browse call. A multi-request export gets no shared snapshot. If every page must reflect one point in time, use database snapshots or locking, or an export job in your application.

Backend parityCopy link to section: Backend parity

Conformance tests keep SQLite and pgvector in step on namespace, filter, order, and cursor behavior. The parity has these limits:

  • kind and status are closed ASCII lowercase enums, so their collation order agrees.
  • Namespace and ID ties use byte/C ordering.
  • Postgres stores confidence as float4, so values that differ below float4 precision may tie there but remain distinct in SQLite.
  • Timestamps use normalized ISO UTC strings.

Within those limits, the same query pages safely on either backend. Outside them, expect serialized rows and order to differ.

Mutations and candidate reviewCopy link to section: Mutations and candidate review

Browse only reads records. Build separate mutation handlers that check the record's tenant before calling update, delete, approval, or rejection. Candidate review already exists through GET /memory/candidates, POST /memory/candidates/:id/approve, and POST /memory/candidates/:id/reject. In a deployed service those routes still need outer authentication and tenant authorization.

Approval can reconcile semantic identities and delete a duplicate candidate. After a mutation, refresh the record. The row you displayed may have changed.

Test the admin surfaceCopy link to section: Test the admin surface

Run the shared memory-store conformance tests against your store, then add application tests that prove:

  1. An unauthenticated or unauthorized caller receives no records.
  2. Request JSON can't override the server-derived tenant namespace prefix.
  3. Namespace, enum, source, time, confidence, content, and sort filters map correctly.
  4. An invalid sort or reused cursor returns a client error.
  5. One fixed now completes a cursor walk, including an exact-multiple empty final page.
  6. Concurrent writes can't skew records from total inside one response.
  7. Cross-tenant mutation IDs are rejected after lookup.

When this page and the code disagree, the exported types, the browse modules, the SQLite and pgvector stores, and their conformance suites win.