Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
76 changes: 76 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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", <action>)`. 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", <action>)` (`add` or `find`). Clients never send
tenant or principal — identity is the context principal.

### The host must resolve tenant + principal for `/api/knowledge/*`

Expand Down
6 changes: 3 additions & 3 deletions src/knowledge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
Expand All @@ -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);
Expand All @@ -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[]) => {
Expand Down
10 changes: 5 additions & 5 deletions src/knowledge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
}
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand Down
27 changes: 13 additions & 14 deletions src/routes/capture.ts → src/routes/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TenantEnv>, deps: RouteDeps): void {
export function mountAddRoute(app: Hono<TenantEnv>, 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);
Expand All @@ -55,11 +54,11 @@ export function mountCaptureRoute(app: Hono<TenantEnv>, 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);
}
},
);
Expand Down
81 changes: 81 additions & 0 deletions src/routes/ask.ts
Original file line number Diff line number Diff line change
@@ -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<TenantEnv>, 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);
}
},
);
}
4 changes: 2 additions & 2 deletions src/routes/deps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
});
});
Loading
Loading