From ebf915f76389d7db0940dd12d2c09dc9a6a50c2c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 24 Sep 2026 23:08:03 -0700 Subject: [PATCH 1/4] refactor(grants): read grant requirements from package.json The interchange.grantRequirements field in the manifest is now the only declaration. The module loads and validates it with arktype and derives its types from that schema; the lockstep test is gone. --- docs/DISTILLER.md | 6 +- src/grant-requirements.test.ts | 27 --------- src/grant-requirements.ts | 106 +++++++++++++-------------------- 3 files changed, 46 insertions(+), 93 deletions(-) diff --git a/docs/DISTILLER.md b/docs/DISTILLER.md index 7a76af4..60f538c 100644 --- a/docs/DISTILLER.md +++ b/docs/DISTILLER.md @@ -102,9 +102,9 @@ on a schedule, e.g. from `@corbits/cron`. This package owns no clock. Installer discovery (not live grants): -- `package.json` → `interchange.grantRequirements` -- typed SSOT: `MEMORY_GRANT_REQUIREMENTS` / `MEMORY_CAPABILITY_IDS` from - `@corbits/memory` +- `package.json` → `interchange.grantRequirements` (the single source) +- loaded in-process as `MEMORY_GRANT_REQUIREMENTS` / `MEMORY_CAPABILITY_IDS` + from `@corbits/memory` Minimum capabilities: diff --git a/src/grant-requirements.test.ts b/src/grant-requirements.test.ts index d6a206c..5427dd9 100644 --- a/src/grant-requirements.test.ts +++ b/src/grant-requirements.test.ts @@ -1,7 +1,4 @@ import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - import { capabilityIdsForSurface, MEMORY_CAPABILITY_IDS, @@ -79,28 +76,4 @@ describe("MEMORY_GRANT_REQUIREMENTS", () => { "memory:search", ]); }); - - test("package.json interchange.grantRequirements stays in lockstep", () => { - const pkg = JSON.parse( - readFileSync(join(import.meta.dir, "..", "package.json"), "utf8"), - ) as { - interchange?: { - grantRequirements?: Array<{ - resource: string; - action: string; - installHint: string; - surfaces: string[]; - }>; - }; - }; - const fromPkg = pkg.interchange?.grantRequirements ?? []; - expect(fromPkg).toEqual( - MEMORY_GRANT_REQUIREMENTS.map((r) => ({ - resource: r.resource, - action: r.action, - installHint: r.installHint, - surfaces: [...r.surfaces], - })), - ); - }); }); diff --git a/src/grant-requirements.ts b/src/grant-requirements.ts index e1f880c..5f8f75e 100644 --- a/src/grant-requirements.ts +++ b/src/grant-requirements.ts @@ -1,85 +1,65 @@ /** * Grant *requirements* for installers — not live grants. * - * Mirrored under `package.json` → `interchange.grantRequirements` so a - * host installer can read npm metadata without executing code. The typed - * export is the in-repo SSOT; keep package.json in lockstep. + * `package.json` → `interchange.grantRequirements` is the single source, so a + * host installer can read npm metadata without executing code; this module + * loads and validates it for in-process callers. * * Shape matches Interchange definition grant requirements * (`resource` + `action` + `installHint`). Control plane materializes grants * onto the workflow principal at deploy/launch. */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { type } from "arktype"; -/** - * Advisory-only sizing hint for install tooling deciding how broadly to mint - * the underlying `resource`/`action` capability grant (e.g. "give every - * tenant member `memory:search`" vs "give this principal `memory:forget` - * scoped to what it creates"). **Nothing in this package reads or enforces - * this value** — it is not a `requireGrant`/`canAccessDocument` mode switch, - * and it does not gate anything at request time. Whether a specific caller - * may actually forget/purge a specific document is decided entirely by the - * imperative creator check in `services/retention-ownership.ts` (wired into - * `memory.ts`), independent of grant tags and of this field. See - * ARCHITECTURE.md § Boundaries for the two-mechanism split. - */ -export type MemoryGrantInstallHint = "tenant" | "creator" | "invoker"; +const GrantRequirement = type({ + resource: "string", + action: "string", + /** + * Advisory-only sizing hint for install tooling deciding how broadly to + * mint the underlying `resource`/`action` capability grant (e.g. "give + * every tenant member `memory:search`" vs "give this principal + * `memory:forget` scoped to what it creates"). **Nothing in this package + * reads or enforces this value** — whether a specific caller may actually + * forget/purge a specific document is decided entirely by the imperative + * creator check in `services/retention-ownership.ts`. See ARCHITECTURE.md + * § Boundaries for the two-mechanism split. + */ + installHint: "'tenant' | 'creator' | 'invoker'", + /** Package surfaces that need the requirement when installed. */ + surfaces: "('tools' | 'distiller' | 'routes')[]", +}); -/** Package surfaces that need the requirement when installed. */ -export type MemoryGrantSurface = "tools" | "distiller" | "routes"; +export type MemoryGrantRequirement = typeof GrantRequirement.infer; +export type MemoryGrantInstallHint = MemoryGrantRequirement["installHint"]; +export type MemoryGrantSurface = MemoryGrantRequirement["surfaces"][number]; -export type MemoryGrantRequirement = { - readonly resource: string; - readonly action: string; - /** Install-sizing hint only — see `MemoryGrantInstallHint`. Not enforced. */ - readonly installHint: MemoryGrantInstallHint; - readonly surfaces: readonly MemoryGrantSurface[]; -}; +const PackageGrantRequirements = type({ + interchange: { grantRequirements: GrantRequirement.array() }, +}); /** * Minimum capability grants for memory tools / routes / process helpers. * Document-tag access (`memory.doc:…`, `memory.space:…`) is separate and * minted per document — not package install requirements. + * + * `forget` and `purge` are `installHint: "creator"`: a sizing suggestion + * only. The per-document ownership check runs in + * `services/retention-ownership.ts` regardless of how broadly they are minted. */ -export const MEMORY_GRANT_REQUIREMENTS = [ - { - resource: "memory", - action: "add", - installHint: "tenant", - surfaces: ["tools", "distiller", "routes"], - }, - { - resource: "memory", - action: "search", - installHint: "tenant", - surfaces: ["tools", "distiller", "routes"], - }, - /** - * Retention writes (CL-6288). Tombstone and retention-class changes share - * `forget`; hard delete gets its own `purge` so a host can hand out "let - * this user forget their own notes" without also handing out irreversible - * deletion. `installHint: "creator"` is a sizing suggestion for install - * tooling ONLY — the real per-document ownership check that stops a - * caller from forgetting/purging someone else's document runs in - * `services/retention-ownership.ts` regardless of how broadly this grant - * was minted. - */ - { - resource: "memory", - action: "forget", - installHint: "creator", - surfaces: ["routes"], - }, - { - resource: "memory", - action: "purge", - installHint: "creator", - surfaces: ["routes"], - }, -] as const satisfies readonly MemoryGrantRequirement[]; +export const MEMORY_GRANT_REQUIREMENTS: readonly MemoryGrantRequirement[] = + PackageGrantRequirements.assert( + JSON.parse( + // dirname (not Bun-only `dir`): package.json sits one level above both + // src/ and the packed dist/. + readFileSync(join(import.meta.dirname, "..", "package.json"), "utf8"), + ), + ).interchange.grantRequirements; /** Compact `resource:action` form used on agent `capabilities` arrays. */ export const MEMORY_CAPABILITY_IDS = MEMORY_GRANT_REQUIREMENTS.map( - (r) => `${r.resource}:${r.action}` as const, + (r) => `${r.resource}:${r.action}`, ); /** @@ -91,6 +71,6 @@ export function capabilityIdsForSurface( surface: MemoryGrantSurface, ): string[] { return MEMORY_GRANT_REQUIREMENTS.filter((r) => - (r.surfaces as readonly MemoryGrantSurface[]).includes(surface), + r.surfaces.includes(surface), ).map((r) => `${r.resource}:${r.action}`); } From 2972434eaa8b750a6e0924ab09d8f40288894d43 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 24 Sep 2026 23:08:15 -0700 Subject: [PATCH 2/4] build(deps): declare the host-shared stack as peer dependencies @intx/*, drizzle-orm, hono, hono-openapi and postgres move to caret peerDependencies floored at the tested versions, with exact devDependency pins; arktype is the only runtime dependency. engines is removed to match Interchange packages and sideEffects is false. --- bun.lock | 28 ++++++++++++++++++++-------- package.json | 25 +++++++++++++++++-------- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/bun.lock b/bun.lock index f385f95..b9b0906 100644 --- a/bun.lock +++ b/bun.lock @@ -5,21 +5,33 @@ "": { "name": "company-knowledge-engine", "dependencies": { + "arktype": "^2.1.29", + }, + "devDependencies": { + "@intx/agent": "0.4.0", + "@intx/authz": "0.4.0", + "@intx/hub-api": "0.4.0", + "@intx/log": "0.4.0", + "@intx/types": "0.4.0", + "@intx/workflow": "0.4.0", + "@types/bun": "1.4.2", + "drizzle-orm": "0.45.2", + "hono": "4.12.31", + "hono-openapi": "1.3.1", + "postgres": "3.4.9", + "typescript": "^5.9.0", + }, + "peerDependencies": { "@intx/agent": "^0.4.0", "@intx/authz": "^0.4.0", "@intx/hub-api": "^0.4.0", "@intx/log": "^0.4.0", "@intx/types": "^0.4.0", "@intx/workflow": "^0.4.0", - "arktype": "^2.1.29", - "drizzle-orm": "^0.45.1", - "hono": "^4.9.0", + "drizzle-orm": "^0.45.2", + "hono": "^4.12.31", "hono-openapi": "^1.3.1", - "postgres": "^3.4.7", - }, - "devDependencies": { - "@types/bun": "1.4.2", - "typescript": "^5.9.0", + "postgres": "^3.4.9", }, }, }, diff --git a/package.json b/package.json index 81d141f..351c5ca 100644 --- a/package.json +++ b/package.json @@ -56,10 +56,6 @@ }, "license": "LGPL-2.1-only", "type": "module", - "engines": { - "bun": ">=1.2.0", - "node": ">=24" - }, "scripts": { "db:setup": "bun run scripts/db-setup.ts", "typecheck": "tsc --noEmit", @@ -69,20 +65,32 @@ "test:coverage": "bun test --coverage --coverage-reporter=lcov --coverage-reporter=text ./src" }, "dependencies": { + "arktype": "^2.1.29" + }, + "peerDependencies": { "@intx/agent": "^0.4.0", "@intx/authz": "^0.4.0", "@intx/hub-api": "^0.4.0", "@intx/log": "^0.4.0", "@intx/types": "^0.4.0", "@intx/workflow": "^0.4.0", - "arktype": "^2.1.29", - "drizzle-orm": "^0.45.1", - "hono": "^4.9.0", + "drizzle-orm": "^0.45.2", + "hono": "^4.12.31", "hono-openapi": "^1.3.1", - "postgres": "^3.4.7" + "postgres": "^3.4.9" }, "devDependencies": { + "@intx/agent": "0.4.0", + "@intx/authz": "0.4.0", + "@intx/hub-api": "0.4.0", + "@intx/log": "0.4.0", + "@intx/types": "0.4.0", + "@intx/workflow": "0.4.0", "@types/bun": "1.4.2", + "drizzle-orm": "0.45.2", + "hono": "4.12.31", + "hono-openapi": "1.3.1", + "postgres": "3.4.9", "typescript": "^5.9.0" }, "author": "Sawyer Cutler ", @@ -108,6 +116,7 @@ "README.md", "LICENSE" ], + "sideEffects": false, "publishConfig": { "access": "public" } From 8558c7655f8f47ea743c7340d8c8b9b309ac93db Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 24 Sep 2026 23:08:19 -0700 Subject: [PATCH 3/4] docs(readme): state the dist build and the actual peers Runtime support no longer claims there is no dist build, and the peer line matches package.json peerDependencies. --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 19b6c41..1ce4155 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,12 @@ not ship an answer endpoint. ## Runtime support -Bun >= 1.2 runs the published TypeScript source (`package.json` `exports`); -there is no `dist` build, so native Node does not load it. `engines.node` is -`>=24` as a floor for Node-side tooling (typecheck, pack). +The package ships compiled JavaScript and type declarations in `dist/`, so it +runs on Node 22+ or Bun. -Peer stack you already have on an Interchange hub: `@intx/authz`, -`@intx/hub-api`, `hono`. +Peer dependencies (the stack an Interchange hub already has): `@intx/agent`, +`@intx/authz`, `@intx/hub-api`, `@intx/log`, `@intx/types`, `@intx/workflow`, +`drizzle-orm`, `hono`, `hono-openapi`, `postgres`. ## Quickstart @@ -204,8 +204,8 @@ bun run test # bun test ./src ``` Tests use `createFakeDocumentStore`/`createFakeSourceProvider` (exported for -this purpose) so the suite runs without Postgres. There is no `build` -script — the published surface is `src/`. +this purpose) so the suite runs without Postgres. `bun run build` compiles +`src/` to `dist/`, which is what the package publishes. ## License From 61ef445bbdcc97422905fe2cf61883d25ec66779 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 24 Sep 2026 23:27:00 -0700 Subject: [PATCH 4/4] docs(changelog): record the peer and grant-requirement changes --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8379770..f086e13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- `@intx/*`, `drizzle-orm`, `hono`, `hono-openapi` and `postgres` are peer + dependencies; the host supplies them. `engines` is removed. +- `MEMORY_GRANT_REQUIREMENTS` is read from `package.json` + `interchange.grantRequirements`, now the only declaration. + `MEMORY_CAPABILITY_IDS` is typed `string[]`. + ### Added - Retention HTTP routes (CL-6288): `POST …/memory/documents/:documentId/forget`