From 84d10acc5bcabde9148dde91d282bca949763a97 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 3 Aug 2026 15:42:17 -0700 Subject: [PATCH] Rename HTTP routes and grants to green verbs (CL-5225) Mount POST add/ask and GET recent under /api/knowledge; drop legacy capture/search/timeline paths. Grant actions become add and the retrieval verb; ask() uses the same retrieval grant. CHANGELOG Unreleased plus MIGRATION.md cover the hard cutover. --- CHANGELOG.md | 24 ++- IMPLEMENTATION.md | 11 +- MIGRATION.md | 76 +++++++++ README.md | 8 +- src/knowledge.test.ts | 6 +- src/knowledge.ts | 10 +- src/routes/{capture.ts => add.ts} | 27 ++-- src/routes/ask.ts | 81 ++++++++++ src/routes/deps.test.ts | 4 +- src/routes/{search.ts => find.ts} | 31 ++-- src/routes/mount.ts | 16 +- src/routes/{timeline.ts => recent.ts} | 24 +-- src/routes/routes.test.ts | 222 +++++++++++++++++--------- 13 files changed, 397 insertions(+), 143 deletions(-) create mode 100644 MIGRATION.md rename src/routes/{capture.ts => add.ts} (66%) create mode 100644 src/routes/ask.ts rename src/routes/{search.ts => find.ts} (70%) rename src/routes/{timeline.ts => recent.ts} (61%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 450ac2f..6a49eb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,31 @@ All notable changes to `@corbits/knowledge-engine` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.1.2] — 2026-07-31 +## [Unreleased] + +### Changed + +- **Breaking:** knowledge plane surface is `add` / `find` / `ask` / `recent` with + `principalId` + `tenantId` only (`capture` / `search` / `timeline` and + `subjectId` / `scopeId` removed). See `MIGRATION.md`. +- **Breaking:** HTTP routes are `POST /api/knowledge/add`, + `POST /api/knowledge/find`, `POST /api/knowledge/ask`, + `GET /api/knowledge/recent`. Old paths are not mounted. +- **Breaking:** grant actions are `add` and `find` (was `capture` / `search`). + `ask` and `recent` use the `find` grant. +- **Breaking:** `add` returns `{ documentId }`; find body uses `limit` (not `k`); + find wire uses `items` (not `hits`). ### Added -- Grant-checked `ask()` on the knowledge plane: retrieves as the principal, grounds a host-supplied `generate` callback, returns citations (`#5`) +- Optional `TextExtractor` + `file` XOR `content` on `add` +- `share` sugar on `add` (maps to existing visibility / block ACL) +- `POST /api/knowledge/ask` HTTP route +- `MIGRATION.md` hard-cutover notes for in-repo consumers + +## [0.1.2] — 2026-07-31 + +### Added - Public `createKnowledgePlane` export for out-of-band capture and search (CLI seeders, batch ingesters, tests) without mounting HTTP routes (`#8`) ### Fixed diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index c5e4cde..87b0b94 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -470,17 +470,18 @@ so knowing it will flip the tenant's live dense channel too. `mountKnowledgeEngine` mounts these onto the host app. Identity is the request principal read off the Interchange context (`caller(c)` → `{ scopeId: principal.tenantId, subjectId: principal.id }`); clients never send -`tenant_id`/`principal_id` — the handlers only read title/text/query/k/acl. +`tenant_id`/`principal_id` — the handlers only read title/text/query/limit/acl. Each route is guarded with `grantGuard(deps, action)`, which applies the host's `requireGrant("knowledge", action)` when provided (else a pass-through). | Method + path | Grant action | Request body | Response | |---|---|---|---| -| `POST /api/knowledge/capture` | `capture` | `{ title, text, acl? }` | `200 { status: "captured" }`; `400` on validation | -| `POST /api/knowledge/search` | `search` | `{ query, k?, kinds?, entity_ids? }` (k 1–50; `kinds`/`entity_ids` narrow every retrieval channel — lexical and dense — to a document `kind` or linked entity id before fusion; unset or `[]` = unfiltered) | `200 SearchResponse` (`{ hits[], evidence, degraded? }`); `400` on bad input | -| `GET /api/knowledge/timeline` | `search` | — | `200 { events: [{ at, title, source, tenantId, principalId }] }` — durable recent documents for the caller's scope (`last_seen_at` DESC), filtered with the same visibility SQL + `acl_block` post-filter as search. One event per document (active live version), not per capture attempt. See wire field notes below. | +| `POST /api/knowledge/add` | `add` | `{ title, text, acl? }` | `200 { documentId }`; `400` on validation | +| `POST /api/knowledge/find` | `find` | `{ query, limit?, kinds?, entity_ids? }` (limit 1–50; `kinds`/`entity_ids` narrow every retrieval channel — lexical and dense — to a document `kind` or linked entity id before fusion; unset or `[]` = unfiltered) | `200 { items[], evidence?, degraded? }`; `400` on bad input | +| `POST /api/knowledge/ask` | `find` | `{ query, limit? }` (1–50) | `200 { text, citations[], evidence }`; `403` / `501` as plane errors | +| `GET /api/knowledge/recent` | `find` | — | `200 { events: [{ at, title, source, tenantId, principalId }] }` — durable recent documents for the caller's scope (`last_seen_at` DESC), filtered with the same visibility SQL + `acl_block` post-filter as find. One event per document (active live version). | -`mountKnowledgeRoutes` and `mountKnowledgeEngine` both mount the three HTTP routes. MCP is a separate package (`@corbitsdev/hono-openapi-mcp`). +`mountKnowledgeRoutes` and `mountKnowledgeEngine` mount the four HTTP routes. MCP is a separate package (`@corbitsdev/hono-openapi-mcp`). ### Timeline wire fields (vs the old CaptureLog ring) diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..3c1939e --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,76 @@ +# Migration guide — green API cutover (0.2.0) + +Hard cutover. There is no dual-path or alias period. Hosts and grant tables must +move to the new names in the same release. + +## Plane surface + +| Was | Now | +| --- | --- | +| `knowledge.capture(params)` | `knowledge.add(params)` | +| `knowledge.search(params)` | `knowledge.find(params)` | +| `knowledge.timeline(params)` | `knowledge.recent(params)` | +| `knowledge.ask(params)` | unchanged verb; grant action changed (below) | + +Identity fields on every call: + +| Was | Now | +| --- | --- | +| `subjectId` | `principalId` | +| `scopeId` | `tenantId` | + +### `add` + +- Returns `{ documentId }` only (no `status` / `versionId` / `chunks` on the public result). +- Exactly one of `content: { title, text }` or `file: { bytes, mimeType?, filename? }`. +- File ingest requires a host-supplied `textExtractor` on the plane options. +- Optional `share` sugar (`private` / `tenant` / `principals`) maps onto the existing ACL path. Do not pass `share` and `visibility` together. + +### `find` + +- Result shape: `{ items: FindItem[], evidence?, degraded? }`. +- `evidence` is omitted unless `includeEvidence: true` (HTTP always sets it). +- Hit field: `documentId` (was `document_id` on internal search hits; plane maps it). +- Limit param: `limit` (1–50), not `k`. + +### `recent` + +- Same event shape as the old timeline; param is `limit` (1–100). + +## HTTP routes + +| Was | Now | +| --- | --- | +| `POST /api/knowledge/capture` | `POST /api/knowledge/add` | +| `POST /api/knowledge/search` | `POST /api/knowledge/find` | +| `GET /api/knowledge/timeline` | `GET /api/knowledge/recent` | +| — | `POST /api/knowledge/ask` (new) | + +Old paths return **404**. No redirect, no dual mount. + +### Wire body / response deltas + +- **add** request: still `{ title, text, acl? }`. Response: `{ documentId }` (dropped `status: "captured"`). +- **find** request: `{ query, limit? }` (`k` is no longer accepted). Response: `{ items, evidence?, degraded? }` (was `{ hits, evidence, degraded? }`). +- **recent** response: unchanged `{ events: [...] }`. +- **ask** request: `{ query, limit? }`. Response: `{ text, citations, evidence }`. + +## Grants + +| Was | Now | +| --- | --- | +| `requireGrant("knowledge", "capture")` | `requireGrant("knowledge", "add")` | +| `requireGrant("knowledge", "search")` | `requireGrant("knowledge", "find")` | + +`find`, `ask`, and `recent` all require the **`find`** action. Old action names +are not accepted — update grant rows in the host grant store before deploy. + +In-process `ask()` also checks `knowledge` / `find` (was `search`). + +## Host checklist (this package's consumers) + +1. Rename plane method calls and identity fields. +2. Point HTTP clients at the new paths and bodies. +3. Rewrite grant rules: `capture`→`add`, `search`→`find`. +4. Drop any reliance on `status: "captured"` or `hits` / `k` on the wire. +5. If you use file capture, pass `textExtractor` into `createKnowledgePlane` / mount options. diff --git a/README.md b/README.md index b71dbed..676cc0d 100644 --- a/README.md +++ b/README.md @@ -59,10 +59,10 @@ mountKnowledgeEngine(app, { }); ``` -That mounts `POST /api/knowledge/capture`, `POST /api/knowledge/search`, and -`GET /api/knowledge/timeline`, each guarded with -`requireGrant("knowledge", )`. Clients never send tenant or principal — -identity is the context principal. +That mounts `POST /api/knowledge/add`, `POST /api/knowledge/find`, +`POST /api/knowledge/ask`, and `GET /api/knowledge/recent`, each guarded with +`requireGrant("knowledge", )` (`add` or `find`). Clients never send +tenant or principal — identity is the context principal. ### The host must resolve tenant + principal for `/api/knowledge/*` diff --git a/src/knowledge.test.ts b/src/knowledge.test.ts index 76481ee..51d150d 100644 --- a/src/knowledge.test.ts +++ b/src/knowledge.test.ts @@ -729,7 +729,7 @@ describe("ask() — grant check", () => { }); it("denies when the only matching grant is an explicit deny", async () => { - const denyGrant: GrantRule = { ...grant("search"), effect: "deny" }; +const denyGrant: GrantRule = { ...grant("find"), effect: "deny" }; const grants = { grantStore: createInMemoryGrantStore([denyGrant]), conditionRegistry: {}, @@ -746,7 +746,7 @@ describe("ask() — missing generate", () => { // Pointed at a nonexistent DB: if find ran first this would surface a // connection/driver error instead of the promised 501. const grants = { - grantStore: createInMemoryGrantStore([grant("search")]), +grantStore: createInMemoryGrantStore([grant("find")]), conditionRegistry: {}, }; const plane = createKnowledgePlane(askConfig, grants); @@ -764,7 +764,7 @@ describe("ask() — missing generate", () => { describe("ask() — allow path", () => { it("finds as the principal and synthesizes when grant allows and generate is wired", async () => { const grants = { - grantStore: createInMemoryGrantStore([grant("search")]), + grantStore: createInMemoryGrantStore([grant("find")]), conditionRegistry: {}, }; const generate = mock((messages: readonly ChatMessage[]) => { diff --git a/src/knowledge.ts b/src/knowledge.ts index 4976d12..038b5d2 100644 --- a/src/knowledge.ts +++ b/src/knowledge.ts @@ -120,10 +120,10 @@ export type AskResult = { evidence: HybridSearchResult["evidence"]; }; -/** Thrown when the asking principal lacks the knowledge:search capability. */ +/** Thrown when the asking principal lacks the knowledge:find capability. */ export class KnowledgeNotPermittedError extends Error { constructor() { - super("principal lacks the knowledge:search grant"); + super("principal lacks the knowledge:find grant"); this.name = "KnowledgeNotPermittedError"; } } @@ -561,7 +561,7 @@ export function createKnowledgePlane( // data layers are independent and BOTH must allow. Per-document // visibility (enforced inside `find`) is not a substitute for "may // this principal search at all". -// HTTP routes still guard with action "search"; ask matches that. +// Same action as HTTP find/ask/recent: knowledge:find. if (!grants) { throw new KnowledgeError( 501, @@ -574,7 +574,7 @@ export function createKnowledgePlane( params.principalId, params.tenantId, "knowledge", - "search", + "find", grants.conditionRegistry, ); // `effect: null` means no grant matched at all — deny by default, same @@ -584,7 +584,7 @@ export function createKnowledgePlane( // template, not the structured context object (see src/log.ts). const effect = decision.effect ?? "no-matching-grant"; log.info( - `ask: denied knowledge:search for ${params.principalId} (effect=${effect})`, + `ask: denied knowledge:find for ${params.principalId} (effect=${effect})`, { principalId: params.principalId, effect, diff --git a/src/routes/capture.ts b/src/routes/add.ts similarity index 66% rename from src/routes/capture.ts rename to src/routes/add.ts index 8737119..3863669 100644 --- a/src/routes/capture.ts +++ b/src/routes/add.ts @@ -8,38 +8,37 @@ import { parseAcl } from "../acl.ts"; import type { RouteDeps } from "./deps.ts"; import { caller, grantGuard, requirePrincipal } from "./deps.ts"; -const CaptureRequest = type({ +const AddRequest = type({ title: "string >= 1", text: "string >= 1", "acl?": "unknown", }); -const CaptureResponse = type({ - status: "'captured'", +const AddResponse = type({ documentId: "string", }); -export function mountCaptureRoute(app: Hono, deps: RouteDeps): void { +export function mountAddRoute(app: Hono, deps: RouteDeps): void { app.post( - "/api/knowledge/capture", + "/api/knowledge/add", describeRoute({ tags: ["knowledge"], - summary: "Capture a note into the knowledge base", + summary: "Add a note into the knowledge base", responses: { 200: { - description: "Captured", + description: "Added", content: { - "application/json": { schema: resolver(CaptureResponse) }, + "application/json": { schema: resolver(AddResponse) }, }, }, 400: { description: "Invalid request or ACL" }, 401: { description: "No principal on the request context" }, - 403: { description: "Missing the knowledge:capture grant" }, + 403: { description: "Missing the knowledge:add grant" }, }, }), requirePrincipal(), - grantGuard(deps, "capture"), - validator("json", CaptureRequest), + grantGuard(deps, "add"), + validator("json", AddRequest), async (c) => { const { title, text, acl } = c.req.valid("json"); const { scopeId, subjectId } = caller(c); @@ -55,11 +54,11 @@ export function mountCaptureRoute(app: Hono, deps: RouteDeps): void { visibility: parsed.visibility, blockPrincipalIds: parsed.block, }); - return c.json({ status: "captured", documentId }); + return c.json({ documentId }); } catch (err) { const errMessage = formatCaughtError(err); - log.error(`knowledge capture failed: ${errMessage}`, { error: errMessage }); - return c.json({ error: "capture failed" }, 502); + log.error(`knowledge add failed: ${errMessage}`, { error: errMessage }); + return c.json({ error: "add failed" }, 502); } }, ); diff --git a/src/routes/ask.ts b/src/routes/ask.ts new file mode 100644 index 0000000..23e3879 --- /dev/null +++ b/src/routes/ask.ts @@ -0,0 +1,81 @@ +import type { Hono } from "hono"; +import type { TenantEnv } from "@intx/hub-api"; +import { describeRoute, resolver, validator } from "hono-openapi"; +import { type } from "arktype"; + +import { formatCaughtError, log } from "../log.ts"; +import { + KnowledgeError, + KnowledgeNotPermittedError, +} from "../knowledge.ts"; +import type { RouteDeps } from "./deps.ts"; +import { caller, grantGuard, requirePrincipal } from "./deps.ts"; + +const AskRequest = type({ + query: "string >= 1", + "limit?": "1 <= number.integer <= 50", +}); + +const AskResponse = type({ + text: "string", + citations: type({ + index: "number", + documentId: "string", + title: "string", + citation: "unknown", + }).array(), + evidence: "'strong'|'weak'|'none'", +}); + +export function mountAskRoute(app: Hono, deps: RouteDeps): void { + app.post( + "/api/knowledge/ask", + describeRoute({ + tags: ["knowledge"], + summary: "Answer a question from retrieved knowledge", + responses: { + 200: { + description: "Grounded answer with citations", + content: { + "application/json": { schema: resolver(AskResponse) }, + }, + }, + 400: { description: "Invalid query" }, + 401: { description: "No principal on the request context" }, + 403: { description: "Missing the knowledge:find grant" }, + 501: { description: "ask is not configured (no generate)" }, + 502: { description: "ask failed" }, + }, + }), + requirePrincipal(), + // Same capability as find — ask retrieves as the principal then synthesizes. + grantGuard(deps, "find"), + validator("json", AskRequest), + async (c) => { + const { query, limit } = c.req.valid("json"); + const { scopeId, subjectId } = caller(c); + try { + const result = await deps.knowledge.ask({ + query, + tenantId: scopeId, + principalId: subjectId, + ...(limit !== undefined ? { limit } : {}), + }); + return c.json(result); + } catch (err) { + if (err instanceof KnowledgeNotPermittedError) { + return c.json({ error: err.message }, 403); + } + if (err instanceof KnowledgeError) { + return c.json( + { error: err.message }, + err.status as 400 | 501, + ); + } + const errMessage = formatCaughtError(err); + log.error(`knowledge ask failed: ${errMessage}`, { err }); + return c.json({ error: "ask failed" }, 502); + } + }, + ); +} diff --git a/src/routes/deps.test.ts b/src/routes/deps.test.ts index 00db3d2..b9fd41d 100644 --- a/src/routes/deps.test.ts +++ b/src/routes/deps.test.ts @@ -111,7 +111,7 @@ describe("grantGuard", () => { called = { resource: String(resource), action }; return (async () => {}) as never; }; - grantGuard(deps(grantsWith(), requireGrant), "capture"); - expect(called).toEqual({ resource: "knowledge", action: "capture" }); +grantGuard(deps(grantsWith(), requireGrant), "add"); + expect(called).toEqual({ resource: "knowledge", action: "add" }); }); }); diff --git a/src/routes/search.ts b/src/routes/find.ts similarity index 70% rename from src/routes/search.ts rename to src/routes/find.ts index e9ebe2d..fc269a6 100644 --- a/src/routes/search.ts +++ b/src/routes/find.ts @@ -9,21 +9,20 @@ import type { RouteDeps } from "./deps.ts"; import { caller, grantGuard, requirePrincipal } from "./deps.ts"; // `kinds`/`entity_ids` scope every retrieval channel — see the -// `kinds`/`entityIds` doc comments on KnowledgeSearchParams (knowledge.ts) +// `kinds`/`entityIds` doc comments on KnowledgeFindParams (knowledge.ts) // for the full explanation. // // An empty array on either field is equivalent to omitting it — "no filter" // — not "match nothing", and does not satisfy the requirement that an empty // `query` be paired with a non-empty structured filter. -const SearchRequest = type({ +const FindRequest = type({ query: "string >= 1", - "k?": "1 <= number.integer <= 50", + "limit?": "1 <= number.integer <= 50", "kinds?": "string[]", "entity_ids?": "string[]", }); -// Green FindResult shape. includeEvidence is always true on HTTP so the wire -// keeps reporting evidence for existing clients. +// includeEvidence is always true on HTTP so the wire reports evidence. const FindResponse = type({ items: type({ documentId: "string", @@ -37,12 +36,12 @@ const FindResponse = type({ "degraded?": "string[]", }); -export function mountSearchRoute(app: Hono, deps: RouteDeps): void { +export function mountFindRoute(app: Hono, deps: RouteDeps): void { app.post( - "/api/knowledge/search", + "/api/knowledge/find", describeRoute({ tags: ["knowledge"], - summary: "Hybrid semantic + keyword search", + summary: "Hybrid semantic + keyword find", description: "`kinds`/`entity_ids` scope every retrieval channel (lexical and " + "dense) before results are fused, so every hit matches the " + @@ -56,35 +55,33 @@ export function mountSearchRoute(app: Hono, deps: RouteDeps): void { }, 400: { description: "Invalid query" }, 401: { description: "No principal on the request context" }, - 403: { description: "Missing the knowledge:search grant" }, + 403: { description: "Missing the knowledge:find grant" }, }, }), requirePrincipal(), - grantGuard(deps, "search"), - validator("json", SearchRequest), + grantGuard(deps, "find"), + validator("json", FindRequest), async (c) => { - const { query, k, kinds, entity_ids } = c.req.valid("json"); + const { query, limit, kinds, entity_ids } = c.req.valid("json"); const { scopeId, subjectId } = caller(c); try { -// Body still accepts k — map to green limit. Always includeEvidence so - // the wire keeps the evidence field clients already rely on. const result = await deps.knowledge.find({ query, tenantId: scopeId, principalId: subjectId, includeEvidence: true, - ...(k !== undefined ? { limit: k } : {}), + ...(limit !== undefined ? { limit } : {}), ...(kinds !== undefined ? { kinds } : {}), ...(entity_ids !== undefined ? { entityIds: entity_ids } : {}), }); return c.json(result); } catch (err) { const errMessage = formatCaughtError(err); - log.error(`knowledge search failed: ${errMessage}`, { err }); + log.error(`knowledge find failed: ${errMessage}`, { err }); if (err instanceof KnowledgeError) { return c.json({ error: err.message }, err.status as 400); } - return c.json({ error: "search failed" }, 502); + return c.json({ error: "find failed" }, 502); } }, ); diff --git a/src/routes/mount.ts b/src/routes/mount.ts index dfa24b7..2534f50 100644 --- a/src/routes/mount.ts +++ b/src/routes/mount.ts @@ -6,18 +6,20 @@ import type { Hono } from "hono"; import type { TenantEnv } from "@intx/hub-api"; import type { RouteDeps } from "./deps.ts"; -import { mountCaptureRoute } from "./capture.ts"; -import { mountSearchRoute } from "./search.ts"; -import { mountTimelineRoute } from "./timeline.ts"; +import { mountAddRoute } from "./add.ts"; +import { mountFindRoute } from "./find.ts"; +import { mountAskRoute } from "./ask.ts"; +import { mountRecentRoute } from "./recent.ts"; export type { GrantConfig, RouteDeps } from "./deps.ts"; -/** HTTP JSON routes: capture, search, timeline. */ +/** HTTP JSON routes: add, find, ask, recent. */ export function mountKnowledgeRoutes( app: Hono, deps: RouteDeps, ): void { - mountSearchRoute(app, deps); - mountCaptureRoute(app, deps); - mountTimelineRoute(app, deps); + mountAddRoute(app, deps); + mountFindRoute(app, deps); + mountAskRoute(app, deps); + mountRecentRoute(app, deps); } diff --git a/src/routes/timeline.ts b/src/routes/recent.ts similarity index 61% rename from src/routes/timeline.ts rename to src/routes/recent.ts index 54c16ef..e71e186 100644 --- a/src/routes/timeline.ts +++ b/src/routes/recent.ts @@ -7,7 +7,7 @@ import { formatCaughtError, log } from "../log.ts"; import type { RouteDeps } from "./deps.ts"; import { caller, grantGuard, requirePrincipal } from "./deps.ts"; -const TimelineResponse = type({ +const RecentResponse = type({ events: type({ at: "string", title: "string", @@ -17,40 +17,40 @@ const TimelineResponse = type({ }).array(), }); -export function mountTimelineRoute(app: Hono, deps: RouteDeps): void { +export function mountRecentRoute(app: Hono, deps: RouteDeps): void { app.get( - "/api/knowledge/timeline", + "/api/knowledge/recent", describeRoute({ tags: ["knowledge"], - summary: "Recent captures for the caller's scope", + summary: "Recent documents for the caller's scope", responses: { 200: { - description: "Recent capture events visible to the caller", + description: "Recent events visible to the caller", content: { - "application/json": { schema: resolver(TimelineResponse) }, + "application/json": { schema: resolver(RecentResponse) }, }, }, 401: { description: "No principal on the request context" }, - 403: { description: "Missing the knowledge:search grant" }, - 502: { description: "Timeline query failed" }, + 403: { description: "Missing the knowledge:find grant" }, + 502: { description: "Recent query failed" }, }, }), requirePrincipal(), - grantGuard(deps, "search"), + grantGuard(deps, "find"), async (c) => { const { scopeId, subjectId } = caller(c); try { -const events = await deps.knowledge.recent({ + const events = await deps.knowledge.recent({ tenantId: scopeId, principalId: subjectId, }); return c.json({ events }); } catch (err) { const errMessage = formatCaughtError(err); - log.error(`knowledge timeline failed: ${errMessage}`, { + log.error(`knowledge recent failed: ${errMessage}`, { error: errMessage, }); - return c.json({ error: "timeline failed" }, 502); + return c.json({ error: "recent failed" }, 502); } }, ); diff --git a/src/routes/routes.test.ts b/src/routes/routes.test.ts index ca232b4..4ca195c 100644 --- a/src/routes/routes.test.ts +++ b/src/routes/routes.test.ts @@ -36,9 +36,9 @@ function stubPlane(opts?: { timelineCatalog?: Array< TimelineEvent & { visibleTo: readonly string[] | "tenant" } >; + askImpl?: KnowledgePlane["ask"]; }) { - const captured: { title: string; tenantId: string; principalId: string }[] = - []; + const added: { title: string; tenantId: string; principalId: string }[] = []; const searched: Array< Pick< Parameters[0], @@ -51,9 +51,11 @@ function stubPlane(opts?: { searched.push({ kinds: p.kinds, entityIds: p.entityIds, limit: p.limit }); return { items: [], evidence: "none" }; }, - ask: async () => ({ text: "", citations: [], evidence: "none" }), + ask: + opts?.askImpl ?? + (async () => ({ text: "stub answer", citations: [], evidence: "none" })), add: async (p) => { - captured.push({ + added.push({ title: p.content?.title ?? "", tenantId: p.tenantId, principalId: p.principalId, @@ -71,7 +73,7 @@ function stubPlane(opts?: { }, close: async () => {}, }; - return { plane, captured, searched }; + return { plane, added, searched }; } function buildApp( @@ -81,9 +83,10 @@ function buildApp( TimelineEvent & { visibleTo: readonly string[] | "tenant" } >; principalId?: string; + askImpl?: KnowledgePlane["ask"]; }, ) { - const { plane, captured, searched } = stubPlane(opts); + const { plane, added, searched } = stubPlane(opts); const grantConfig = { grantStore: createInMemoryGrantStore(grants), conditionRegistry: {}, @@ -121,7 +124,7 @@ function buildApp( await next(); }); mountKnowledgeRoutes(app, deps); - return { app, captured, searched }; + return { app, added, searched }; } // Mirrors a host that mounts the knowledge routes outside the tenant prefix @@ -148,7 +151,7 @@ const jsonPost = (body: unknown) => ({ body: JSON.stringify(body), }); -const TIMELINE_CATALOG: Array< +const RECENT_CATALOG: Array< TimelineEvent & { visibleTo: readonly string[] | "tenant" } > = [ { @@ -171,50 +174,56 @@ const TIMELINE_CATALOG: Array< ]; describe("knowledge HTTP routes", () => { - test("capture with the capture grant writes under the caller's scope", async () => { - const { app, captured } = buildApp([ - grant(PRINCIPAL, "capture"), - grant(PRINCIPAL, "search"), + test("add with the add grant writes under the caller's scope", async () => { + const { app, added } = buildApp([ + grant(PRINCIPAL, "add"), + grant(PRINCIPAL, "find"), ]); const res = await app.request( - "/api/knowledge/capture", + "/api/knowledge/add", jsonPost({ title: "t", text: "body" }), ); expect(res.status).toBe(200); - const body = (await res.json()) as { - status: string; - documentId: string; - }; - expect(body.status).toBe("captured"); + const body = (await res.json()) as { documentId: string }; expect(body.documentId).toBe("doc-stub"); - expect(captured).toEqual([ + expect(added).toEqual([ { title: "t", tenantId: TENANT, principalId: PRINCIPAL }, ]); }); - test("capture without the capture grant is 403", async () => { - const { app, captured } = buildApp([grant(PRINCIPAL, "search")]); + test("add without the add grant is 403", async () => { + const { app, added } = buildApp([grant(PRINCIPAL, "find")]); const res = await app.request( - "/api/knowledge/capture", + "/api/knowledge/add", jsonPost({ title: "t", text: "body" }), ); expect(res.status).toBe(403); - expect(captured).toHaveLength(0); + expect(added).toHaveLength(0); }); - test("capture validates the body (400 on missing text)", async () => { - const { app } = buildApp([grant(PRINCIPAL, "capture")]); + test("legacy capture grant does not authorize add", async () => { + const { app, added } = buildApp([grant(PRINCIPAL, "capture")]); const res = await app.request( - "/api/knowledge/capture", + "/api/knowledge/add", + jsonPost({ title: "t", text: "body" }), + ); + expect(res.status).toBe(403); + expect(added).toHaveLength(0); + }); + + test("add validates the body (400 on missing text)", async () => { + const { app } = buildApp([grant(PRINCIPAL, "add")]); + const res = await app.request( + "/api/knowledge/add", jsonPost({ title: "t" }), ); expect(res.status).toBe(400); }); - test("search with the search grant returns a result", async () => { - const { app } = buildApp([grant(PRINCIPAL, "search")]); + test("find with the find grant returns a result", async () => { + const { app } = buildApp([grant(PRINCIPAL, "find")]); const res = await app.request( - "/api/knowledge/search", + "/api/knowledge/find", jsonPost({ query: "hello" }), ); expect(res.status).toBe(200); @@ -226,28 +235,37 @@ describe("knowledge HTTP routes", () => { expect(body.evidence).toBe("none"); }); - test("search requires the search grant", async () => { - const { app } = buildApp([grant(PRINCIPAL, "capture")]); + test("find requires the find grant", async () => { + const { app } = buildApp([grant(PRINCIPAL, "add")]); const res = await app.request( - "/api/knowledge/search", + "/api/knowledge/find", jsonPost({ query: "hi" }), ); expect(res.status).toBe(403); }); - test("search rejects out-of-range k (400)", async () => { + test("legacy search grant does not authorize find", async () => { const { app } = buildApp([grant(PRINCIPAL, "search")]); const res = await app.request( - "/api/knowledge/search", - jsonPost({ query: "hi", k: 999 }), + "/api/knowledge/find", + jsonPost({ query: "hi" }), + ); + expect(res.status).toBe(403); + }); + + test("find rejects out-of-range limit (400)", async () => { + const { app } = buildApp([grant(PRINCIPAL, "find")]); + const res = await app.request( + "/api/knowledge/find", + jsonPost({ query: "hi", limit: 999 }), ); expect(res.status).toBe(400); }); - test("search threads kinds and entity_ids through to the plane", async () => { - const { app, searched } = buildApp([grant(PRINCIPAL, "search")]); + test("find threads kinds and entity_ids through to the plane", async () => { + const { app, searched } = buildApp([grant(PRINCIPAL, "find")]); const res = await app.request( - "/api/knowledge/search", + "/api/knowledge/find", jsonPost({ query: "hello", kinds: ["artifact", "task"], @@ -264,10 +282,10 @@ describe("knowledge HTTP routes", () => { ]); }); - test("search with no kinds/entity_ids leaves them unset on the plane call", async () => { - const { app, searched } = buildApp([grant(PRINCIPAL, "search")]); + test("find with no kinds/entity_ids leaves them unset on the plane call", async () => { + const { app, searched } = buildApp([grant(PRINCIPAL, "find")]); const res = await app.request( - "/api/knowledge/search", + "/api/knowledge/find", jsonPost({ query: "hello" }), ); expect(res.status).toBe(200); @@ -276,10 +294,10 @@ describe("knowledge HTTP routes", () => { ]); }); - test("search rejects a non-string-array kinds (400)", async () => { - const { app } = buildApp([grant(PRINCIPAL, "search")]); + test("find rejects a non-string-array kinds (400)", async () => { + const { app } = buildApp([grant(PRINCIPAL, "find")]); const res = await app.request( - "/api/knowledge/search", + "/api/knowledge/find", jsonPost({ query: "hi", kinds: [1, 2] }), ); expect(res.status).toBe(400); @@ -287,10 +305,10 @@ describe("knowledge HTTP routes", () => { // The route does not collapse [] to absent — hybridSearch treats an empty // array and an absent field the same way (see services/search.ts). - test("search passes an empty kinds/entity_ids array through unchanged", async () => { - const { app, searched } = buildApp([grant(PRINCIPAL, "search")]); + test("find passes an empty kinds/entity_ids array through unchanged", async () => { + const { app, searched } = buildApp([grant(PRINCIPAL, "find")]); const res = await app.request( - "/api/knowledge/search", + "/api/knowledge/find", jsonPost({ query: "hello", kinds: [], entity_ids: [] }), ); expect(res.status).toBe(200); @@ -299,32 +317,56 @@ describe("knowledge HTTP routes", () => { ]); }); - test("timeline requires the search grant", async () => { - const { app } = buildApp([grant(PRINCIPAL, "capture")]); - const res = await app.request("/api/knowledge/timeline"); + test("ask requires the find grant (same as find)", async () => { + const { app } = buildApp([grant(PRINCIPAL, "add")]); + const res = await app.request( + "/api/knowledge/ask", + jsonPost({ query: "what?" }), + ); + expect(res.status).toBe(403); + }); + + test("ask with the find grant returns a grounded answer", async () => { + const { app } = buildApp([grant(PRINCIPAL, "find")]); + const res = await app.request( + "/api/knowledge/ask", + jsonPost({ query: "what?" }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + text: string; + citations: unknown[]; + evidence: string; + }; + expect(body.text).toBe("stub answer"); + expect(body.citations).toEqual([]); + expect(body.evidence).toBe("none"); + }); + + test("recent requires the find grant", async () => { + const { app } = buildApp([grant(PRINCIPAL, "add")]); + const res = await app.request("/api/knowledge/recent"); expect(res.status).toBe(403); }); - test("timeline never returns a title private to another principal", async () => { - // Catalog contains a private secret title. Caller PRINCIPAL is not on - // its visibleTo list — the plane filters by principalId the route passes. - const { app } = buildApp([grant(PRINCIPAL, "search")], { - timelineCatalog: TIMELINE_CATALOG, + test("recent never returns a title private to another principal", async () => { + const { app } = buildApp([grant(PRINCIPAL, "find")], { + timelineCatalog: RECENT_CATALOG, principalId: PRINCIPAL, }); - const res = await app.request("/api/knowledge/timeline"); + const res = await app.request("/api/knowledge/recent"); expect(res.status).toBe(200); const body = (await res.json()) as { events: TimelineEvent[] }; expect(body.events.map((e) => e.title)).toEqual([PUBLIC_TITLE]); expect(body.events.map((e) => e.title)).not.toContain(SECRET_TITLE); }); - test("timeline returns a private title only to the allowed principal", async () => { - const { app } = buildApp([grant("alice", "search")], { - timelineCatalog: TIMELINE_CATALOG, + test("recent returns a private title only to the allowed principal", async () => { + const { app } = buildApp([grant("alice", "find")], { + timelineCatalog: RECENT_CATALOG, principalId: "alice", }); - const res = await app.request("/api/knowledge/timeline"); + const res = await app.request("/api/knowledge/recent"); expect(res.status).toBe(200); const body = (await res.json()) as { events: TimelineEvent[] }; expect(body.events.map((e) => e.title).sort()).toEqual( @@ -332,17 +374,17 @@ describe("knowledge HTTP routes", () => { ); }); - test("timeline scopes by the caller's principal (different principal → different events)", async () => { - const { app: appP1 } = buildApp([grant(PRINCIPAL, "search")], { - timelineCatalog: TIMELINE_CATALOG, + test("recent scopes by the caller's principal (different principal → different events)", async () => { + const { app: appP1 } = buildApp([grant(PRINCIPAL, "find")], { + timelineCatalog: RECENT_CATALOG, principalId: PRINCIPAL, }); - const { app: appOther } = buildApp([grant(OTHER, "search")], { - timelineCatalog: TIMELINE_CATALOG, + const { app: appOther } = buildApp([grant(OTHER, "find")], { + timelineCatalog: RECENT_CATALOG, principalId: OTHER, }); const titles = async (app: Hono) => { - const res = await app.request("/api/knowledge/timeline"); + const res = await app.request("/api/knowledge/recent"); expect(res.status).toBe(200); const body = (await res.json()) as { events: TimelineEvent[] }; return body.events.map((e) => e.title); @@ -353,27 +395,63 @@ describe("knowledge HTTP routes", () => { expect(await titles(appOther)).not.toContain(SECRET_TITLE); }); - test("capture is rejected (401) when no principal is on the context", async () => { + test("old paths are not mounted (hard cutover, no fallback)", async () => { + const { app } = buildApp([ + grant(PRINCIPAL, "add"), + grant(PRINCIPAL, "find"), + grant(PRINCIPAL, "capture"), + grant(PRINCIPAL, "search"), + ]); + for (const path of [ + "/api/knowledge/capture", + "/api/knowledge/search", + "/api/knowledge/timeline", + ]) { + const method = path.endsWith("timeline") ? "GET" : "POST"; + const res = await app.request( + path, + method === "GET" + ? undefined + : jsonPost( + path.includes("capture") + ? { title: "t", text: "body" } + : { query: "hi" }, + ), + ); + expect(res.status).toBe(404); + } + }); + + test("add is rejected (401) when no principal is on the context", async () => { const app = buildAppWithoutPrincipal(); const res = await app.request( - "/api/knowledge/capture", + "/api/knowledge/add", jsonPost({ title: "t", text: "body" }), ); expect(res.status).toBe(401); }); - test("search is rejected (401) when no principal is on the context", async () => { + test("find is rejected (401) when no principal is on the context", async () => { const app = buildAppWithoutPrincipal(); const res = await app.request( - "/api/knowledge/search", + "/api/knowledge/find", + jsonPost({ query: "hello" }), + ); + expect(res.status).toBe(401); + }); + + test("ask is rejected (401) when no principal is on the context", async () => { + const app = buildAppWithoutPrincipal(); + const res = await app.request( + "/api/knowledge/ask", jsonPost({ query: "hello" }), ); expect(res.status).toBe(401); }); - test("timeline is rejected (401) when no principal is on the context", async () => { + test("recent is rejected (401) when no principal is on the context", async () => { const app = buildAppWithoutPrincipal(); - const res = await app.request("/api/knowledge/timeline"); + const res = await app.request("/api/knowledge/recent"); expect(res.status).toBe(401); }); });