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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
28 changes: 20 additions & 8 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions docs/DISTILLER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
25 changes: 17 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 <sawyer@dirtroad.dev>",
Expand All @@ -108,6 +116,7 @@
"README.md",
"LICENSE"
],
"sideEffects": false,
"publishConfig": {
"access": "public"
}
Expand Down
27 changes: 0 additions & 27 deletions src/grant-requirements.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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],
})),
);
});
});
106 changes: 43 additions & 63 deletions src/grant-requirements.ts
Original file line number Diff line number Diff line change
@@ -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}`,
);

/**
Expand All @@ -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}`);
}
Loading