From 821631bf4ebb66e210bdaf99a2b7330f0998a795 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 4 Aug 2026 12:07:37 -0700 Subject: [PATCH] Replace document mini-ACL with Interchange grant tags Hard cutover: documents store access_tags evaluated through the host GrantStore (creator-always + tag authorize). Drop visibility modes, principal arrays, and dual-read block lists from the engine path. - Squash migrations to 0001 extensions + 0002 knowledge baseline - Plane/store/ports/HTTP add share sugars mint tags only - Adapter packages stay principal-bucket DocumentStores --- migrations/0001_extensions.sql | 7 +- migrations/0002_knowledge_baseline.sql | 179 ++++++++++ migrations/0002_knowledge_document.sql | 32 -- migrations/0003_knowledge_version.sql | 49 --- migrations/0004_knowledge_chunk.sql | 24 -- migrations/0005_knowledge_entity.sql | 16 - migrations/0006_knowledge_edge.sql | 37 -- migrations/0007_knowledge_embed_model.sql | 14 - migrations/0008_raw_capture.sql | 27 -- migrations/0009_transform_pipeline.sql | 48 --- packages/knowledge-adapter-mem0/README.md | 2 +- .../src/create-mem0-document-store.test.ts | 4 +- .../src/create-mem0-document-store.ts | 12 +- packages/knowledge-adapter-mem0/src/index.ts | 1 - packages/knowledge-adapter-mem0/src/types.ts | 11 +- .../knowledge-adapter-supermemory/README.md | 2 +- .../src/index.test.ts | 8 +- .../src/index.ts | 23 +- src/acl.test.ts | 300 +++++++--------- src/acl.ts | 288 +++++++-------- src/core/adapt-and-plan.test.ts | 2 +- src/core/fts-language.test.ts | 4 +- src/core/schemas/adapted-document.test.ts | 6 +- src/core/schemas/adapted-document.ts | 8 +- src/core/schemas/document.test.ts | 19 +- src/core/schemas/document.ts | 16 +- src/db/schema.ts | 6 +- src/index.ts | 1 - src/knowledge.test.ts | 141 ++++---- src/knowledge.ts | 234 +++++++------ src/ports/fakes.test.ts | 31 +- src/ports/fakes.ts | 139 ++++---- src/ports/merge-plane.test.ts | 2 +- src/ports/types.ts | 30 +- src/routes/add.ts | 36 +- src/services/capture.ts | 9 +- src/services/search.test.ts | 65 ---- src/services/search.ts | 95 +---- src/services/timeline.test.ts | 331 ++++-------------- src/services/timeline.ts | 223 ++++++------ 40 files changed, 998 insertions(+), 1484 deletions(-) create mode 100644 migrations/0002_knowledge_baseline.sql delete mode 100644 migrations/0002_knowledge_document.sql delete mode 100644 migrations/0003_knowledge_version.sql delete mode 100644 migrations/0004_knowledge_chunk.sql delete mode 100644 migrations/0005_knowledge_entity.sql delete mode 100644 migrations/0006_knowledge_edge.sql delete mode 100644 migrations/0007_knowledge_embed_model.sql delete mode 100644 migrations/0008_raw_capture.sql delete mode 100644 migrations/0009_transform_pipeline.sql diff --git a/migrations/0001_extensions.sql b/migrations/0001_extensions.sql index ee17c77..c54e2c2 100644 --- a/migrations/0001_extensions.sql +++ b/migrations/0001_extensions.sql @@ -1,8 +1,5 @@ --- Own Postgres schema for every knowledge-engine table. Host control-plane --- tables stay in public; this package never collides with or adopts them. +-- Knowledge plane owns its own Postgres schema. Never pollutes public. CREATE SCHEMA IF NOT EXISTS "knowledge"; --- pgvector powers the dense retrieval channel. Per-model --- "knowledge"."embedding_" vector tables are created at runtime by the --- embed-model activation path (dimensionality varies by model), not here. +-- pgvector for dense embeddings (cosine distance / HNSW). CREATE EXTENSION IF NOT EXISTS vector; diff --git a/migrations/0002_knowledge_baseline.sql b/migrations/0002_knowledge_baseline.sql new file mode 100644 index 0000000..a9bc328 --- /dev/null +++ b/migrations/0002_knowledge_baseline.sql @@ -0,0 +1,179 @@ +-- Single baseline for the knowledge plane (grant-tag authz from day one). +-- Document access is access_tags + creator post-filter; no visibility_* columns. + +CREATE TABLE IF NOT EXISTS "knowledge"."document" ( + "id" text PRIMARY KEY, + "tenant_id" text NOT NULL, + "kind" text NOT NULL, + "title" text NOT NULL, + "adapter" text NOT NULL, + "external_ref" text NOT NULL, + "access_tags" text[] NOT NULL DEFAULT '{}', + "attributes" jsonb NOT NULL DEFAULT '{}', + "created_at" timestamp NOT NULL DEFAULT now(), + "last_seen_at" timestamp NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS "document_tenant_adapter_external_ref_uniq" + ON "knowledge"."document" ("tenant_id", "adapter", "external_ref"); + +CREATE TABLE IF NOT EXISTS "knowledge"."raw_capture" ( + "id" text PRIMARY KEY, + "tenant_id" text NOT NULL, + "adapter" text NOT NULL, + "external_ref" text NOT NULL, + "fetched_at" timestamp NOT NULL DEFAULT now(), + "content_type" text NOT NULL, + "raw_text" text, + "raw_bytes" bytea, + "metadata" jsonb NOT NULL DEFAULT '{}', + "source_hash" text NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS "raw_capture_tenant_source_hash_uniq" + ON "knowledge"."raw_capture" ("tenant_id", "source_hash"); + +CREATE INDEX IF NOT EXISTS "raw_capture_tenant_adapter_external_ref_idx" + ON "knowledge"."raw_capture" ("tenant_id", "adapter", "external_ref"); + +CREATE TABLE IF NOT EXISTS "knowledge"."version" ( + "id" text PRIMARY KEY, + "tenant_id" text NOT NULL, + "document_id" text NOT NULL REFERENCES "knowledge"."document" ("id") ON DELETE CASCADE, + "version" integer NOT NULL, + "supersedes_version_id" text, + "status" text NOT NULL DEFAULT 'active', + "content_hash" text NOT NULL, + "occurred_at" timestamp NOT NULL, + "ingested_at" timestamp NOT NULL DEFAULT now(), + "deprecated_at" timestamp, + "deprecated_reason" text, + "created_by_principal_id" text, + "created_by_kind" text NOT NULL, + "generator_agent_id" text, + "authority" real NOT NULL DEFAULT 0, + "actor_count" integer NOT NULL DEFAULT 1, + "has_social_signal" boolean NOT NULL DEFAULT false, + "source_class" text NOT NULL DEFAULT 'native', + "raw_capture_id" text REFERENCES "knowledge"."raw_capture" ("id"), + "generation" text NOT NULL DEFAULT 'live', + CONSTRAINT "version_status_check" + CHECK ("status" IN ('active', 'superseded', 'deprecated', 'archived', 'tombstoned')), + CONSTRAINT "version_created_by_kind_check" + CHECK ("created_by_kind" IN ('human', 'agent', 'system', 'adapter')), + CONSTRAINT "version_source_class_check" + CHECK ("source_class" IN ('native', 'imported', 'derived')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS "version_document_generation_version_uniq" + ON "knowledge"."version" ("document_id", "generation", "version"); + +CREATE INDEX IF NOT EXISTS "version_document_status_idx" + ON "knowledge"."version" ("document_id", "status"); + +CREATE TABLE IF NOT EXISTS "knowledge"."chunk" ( + "id" text PRIMARY KEY, + "tenant_id" text NOT NULL, + "version_id" text NOT NULL REFERENCES "knowledge"."version" ("id") ON DELETE CASCADE, + "document_id" text NOT NULL REFERENCES "knowledge"."document" ("id") ON DELETE CASCADE, + "ordinal" integer NOT NULL, + "text" text NOT NULL, + "role" text, + "created_at" timestamp NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS "chunk_version_ordinal_uniq" + ON "knowledge"."chunk" ("version_id", "ordinal"); + +-- {{FTS_LANGUAGE}} is substituted by runKnowledgeMigrations from FTS_LANGUAGE +-- (or opts.ftsLanguage). Must match the language used at query time. +ALTER TABLE "knowledge"."chunk" + ADD COLUMN IF NOT EXISTS "text_fts" tsvector + GENERATED ALWAYS AS (to_tsvector('{{FTS_LANGUAGE}}', "text")) STORED; + +CREATE INDEX IF NOT EXISTS "chunk_text_fts_idx" + ON "knowledge"."chunk" USING GIN ("text_fts"); + +CREATE TABLE IF NOT EXISTS "knowledge"."entity" ( + "id" text PRIMARY KEY, + "tenant_id" text NOT NULL, + "kind" text NOT NULL, + "identifiers" jsonb NOT NULL DEFAULT '{}', + "created_at" timestamp NOT NULL DEFAULT now(), + "updated_at" timestamp NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS "entity_tenant_kind_idx" + ON "knowledge"."entity" ("tenant_id", "kind"); + +CREATE TABLE IF NOT EXISTS "knowledge"."edge" ( + "id" text PRIMARY KEY, + "tenant_id" text NOT NULL, + "rel" text NOT NULL, + "from_type" text NOT NULL, + "from_ref" text NOT NULL, + "to_type" text NOT NULL, + "to_ref" text NOT NULL, + "created_at" timestamp NOT NULL DEFAULT now(), + CONSTRAINT "edge_rel_check" + CHECK ("rel" IN ( + 'mentions', 'about', 'authored_by', 'involves', + 'part_of', 'derived_from', 'supports', 'contradicts', 'supersedes' + )), + CONSTRAINT "edge_from_type_check" + CHECK ("from_type" IN ('document', 'version', 'chunk', 'entity')), + CONSTRAINT "edge_to_type_check" + CHECK ("to_type" IN ('document', 'version', 'chunk', 'entity')) +); + +CREATE INDEX IF NOT EXISTS "edge_from_idx" + ON "knowledge"."edge" ("tenant_id", "from_type", "from_ref"); + +CREATE INDEX IF NOT EXISTS "edge_to_idx" + ON "knowledge"."edge" ("tenant_id", "to_type", "to_ref"); + +CREATE TABLE IF NOT EXISTS "knowledge"."embed_model" ( + "id" text PRIMARY KEY, + "tenant_id" text NOT NULL, + "model_key" text NOT NULL, + "model_id" text NOT NULL, + "dims" integer NOT NULL, + "status" text NOT NULL DEFAULT 'active', + "created_at" timestamp NOT NULL DEFAULT now(), + "updated_at" timestamp NOT NULL DEFAULT now(), + CONSTRAINT "embed_model_status_check" + CHECK ("status" IN ('active', 'retired')) +); + +CREATE TABLE IF NOT EXISTS "knowledge"."transform_config" ( + "id" text PRIMARY KEY, + "tenant_id" text NOT NULL, + "name" text NOT NULL, + "version" integer NOT NULL, + "params" jsonb NOT NULL DEFAULT '{}', + "created_at" timestamp NOT NULL DEFAULT now(), + CONSTRAINT "transform_config_tenant_name_version_uniq" + UNIQUE ("tenant_id", "name", "version") +); + +CREATE TABLE IF NOT EXISTS "knowledge"."transform_run" ( + "id" text PRIMARY KEY, + "tenant_id" text NOT NULL, + "config_id" text NOT NULL REFERENCES "knowledge"."transform_config" ("id"), + "scope" jsonb NOT NULL DEFAULT '{}', + "generation" text NOT NULL, + "status" text NOT NULL DEFAULT 'running', + "raw_count" integer NOT NULL DEFAULT 0, + "version_count" integer NOT NULL DEFAULT 0, + "error" text, + "created_at" timestamp NOT NULL DEFAULT now(), + "completed_at" timestamp, + CONSTRAINT "transform_run_status_check" + CHECK ("status" IN ('running', 'completed', 'failed')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS "transform_run_generation_uniq" + ON "knowledge"."transform_run" ("generation"); + +CREATE INDEX IF NOT EXISTS "transform_run_tenant_config_idx" + ON "knowledge"."transform_run" ("tenant_id", "config_id"); diff --git a/migrations/0002_knowledge_document.sql b/migrations/0002_knowledge_document.sql deleted file mode 100644 index 320649d..0000000 --- a/migrations/0002_knowledge_document.sql +++ /dev/null @@ -1,32 +0,0 @@ --- The stable logical row for a captured source (an artifact, task, workflow --- run, memory item, or external adapter pull). Deduped by the source-adapter --- connect key (tenant_id, adapter, external_ref); re-ingest of an unchanged --- content_hash bumps last_seen_at and creates no new version. --- --- Identity/ACL lives here: tenant_id scopes every query; visibility_mode + --- the two visibility_* columns are the self-contained ACL the caller passes. --- tenant_id is plain text (no FK into control-plane tables). -CREATE TABLE IF NOT EXISTS "knowledge"."document" ( - "id" text PRIMARY KEY, - "tenant_id" text NOT NULL, - "kind" text NOT NULL, - "title" text NOT NULL, - "adapter" text NOT NULL, - "external_ref" text NOT NULL, - "visibility_mode" text NOT NULL, - "visibility_principal_ids" jsonb, - "visibility_source_acl" jsonb, - "attributes" jsonb NOT NULL DEFAULT '{}', - "created_at" timestamp NOT NULL DEFAULT now(), - "last_seen_at" timestamp NOT NULL DEFAULT now(), - CONSTRAINT "document_visibility_mode_check" CHECK ( - "visibility_mode" IN ('tenant', 'principals', 'source_acl', 'private') - ) -); - -CREATE UNIQUE INDEX IF NOT EXISTS "document_tenant_adapter_external_ref_uniq" - ON "knowledge"."document" ( - "tenant_id", - "adapter", - "external_ref" - ); diff --git a/migrations/0003_knowledge_version.sql b/migrations/0003_knowledge_version.sql deleted file mode 100644 index 8c4ad06..0000000 --- a/migrations/0003_knowledge_version.sql +++ /dev/null @@ -1,49 +0,0 @@ --- The versioned body of a knowledge.document. version is a monotonic int per --- document; status tracks the version/supersede/deprecate lifecycle. Chunks --- belong to a version_id and are never reused across versions. --- --- Attribution ("who") lives here: created_by_principal_id + created_by_kind --- (human/agent/system/adapter). The authority_* columns are a per-version --- snapshot of the corroboration signals computed at capture time (never --- recomputed retroactively) and consumed as a rank prior at search time. -CREATE TABLE IF NOT EXISTS "knowledge"."version" ( - "id" text PRIMARY KEY, - "tenant_id" text NOT NULL, - "document_id" text NOT NULL REFERENCES "knowledge"."document" ("id") ON DELETE CASCADE, - "version" integer NOT NULL, - "supersedes_version_id" text, - "status" text NOT NULL DEFAULT 'active', - "content_hash" text NOT NULL, - "occurred_at" timestamp NOT NULL, - "ingested_at" timestamp NOT NULL DEFAULT now(), - "deprecated_at" timestamp, - "deprecated_reason" text, - "created_by_principal_id" text, - "created_by_kind" text NOT NULL, - "generator_agent_id" text, - "authority" real NOT NULL DEFAULT 0, - "actor_count" integer NOT NULL DEFAULT 1, - "has_social_signal" boolean NOT NULL DEFAULT false, - "source_class" text NOT NULL DEFAULT 'native', - CONSTRAINT "version_status_check" CHECK ( - "status" IN ('active', 'superseded', 'deprecated', 'archived', 'tombstoned') - ), - CONSTRAINT "version_created_by_kind_check" CHECK ( - "created_by_kind" IN ('human', 'agent', 'system', 'adapter') - ), - CONSTRAINT "version_source_class_check" CHECK ( - "source_class" IN ('native', 'thread', 'channel', 'call', 'record') - ) -); - -CREATE UNIQUE INDEX IF NOT EXISTS "version_document_version_uniq" - ON "knowledge"."version" ( - "document_id", - "version" - ); - -CREATE INDEX IF NOT EXISTS "version_document_status_idx" - ON "knowledge"."version" ( - "document_id", - "status" - ); diff --git a/migrations/0004_knowledge_chunk.sql b/migrations/0004_knowledge_chunk.sql deleted file mode 100644 index e460e42..0000000 --- a/migrations/0004_knowledge_chunk.sql +++ /dev/null @@ -1,24 +0,0 @@ --- An ordered slice of a knowledge.version's text, keyed by (version_id, --- ordinal). text_fts is a generated tsvector for the lexical search channel — --- no vector column here (per-model embedding tables are created separately, --- since dimensionality varies by model). -CREATE TABLE IF NOT EXISTS "knowledge"."chunk" ( - "id" text PRIMARY KEY, - "tenant_id" text NOT NULL, - "version_id" text NOT NULL REFERENCES "knowledge"."version" ("id") ON DELETE CASCADE, - "document_id" text NOT NULL REFERENCES "knowledge"."document" ("id") ON DELETE CASCADE, - "ordinal" integer NOT NULL, - "text" text NOT NULL, - "role" text, - "created_at" timestamp NOT NULL DEFAULT now(), - "text_fts" tsvector GENERATED ALWAYS AS (to_tsvector('{{FTS_LANGUAGE}}', "text")) STORED -); - -CREATE UNIQUE INDEX IF NOT EXISTS "chunk_version_ordinal_uniq" - ON "knowledge"."chunk" ( - "version_id", - "ordinal" - ); - -CREATE INDEX IF NOT EXISTS "chunk_text_fts_idx" - ON "knowledge"."chunk" USING GIN ("text_fts"); diff --git a/migrations/0005_knowledge_entity.sql b/migrations/0005_knowledge_entity.sql deleted file mode 100644 index b6ae492..0000000 --- a/migrations/0005_knowledge_entity.sql +++ /dev/null @@ -1,16 +0,0 @@ --- A real-world thing (person, org, deal, ...) a document or chunk mentions. --- Identity keys only (email, domain, ...) — not another copy of chunk text. -CREATE TABLE IF NOT EXISTS "knowledge"."entity" ( - "id" text PRIMARY KEY, - "tenant_id" text NOT NULL, - "kind" text NOT NULL, - "identifiers" jsonb NOT NULL DEFAULT '{}', - "created_at" timestamp NOT NULL DEFAULT now(), - "updated_at" timestamp NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS "entity_tenant_kind_idx" - ON "knowledge"."entity" ( - "tenant_id", - "kind" - ); diff --git a/migrations/0006_knowledge_edge.sql b/migrations/0006_knowledge_edge.sql deleted file mode 100644 index ecd862e..0000000 --- a/migrations/0006_knowledge_edge.sql +++ /dev/null @@ -1,37 +0,0 @@ --- Lightweight graph structure between documents, entities, and native refs --- (e.g. a principal) — never another copy of chunk text. This is the "series --- of relations" every record can carry; relation-following ingestion writes --- `mentions` edges here. -CREATE TABLE IF NOT EXISTS "knowledge"."edge" ( - "id" text PRIMARY KEY, - "tenant_id" text NOT NULL, - "rel" text NOT NULL, - "from_type" text NOT NULL, - "from_ref" text NOT NULL, - "to_type" text NOT NULL, - "to_ref" text NOT NULL, - "created_at" timestamp NOT NULL DEFAULT now(), - CONSTRAINT "edge_rel_check" CHECK ( - "rel" IN ('about', 'produced_by', 'links', 'parent', 'mentions', 'waiting_on') - ), - CONSTRAINT "edge_from_type_check" CHECK ( - "from_type" IN ('document', 'entity', 'native') - ), - CONSTRAINT "edge_to_type_check" CHECK ( - "to_type" IN ('document', 'entity', 'native') - ) -); - -CREATE INDEX IF NOT EXISTS "edge_from_idx" - ON "knowledge"."edge" ( - "tenant_id", - "from_type", - "from_ref" - ); - -CREATE INDEX IF NOT EXISTS "edge_to_idx" - ON "knowledge"."edge" ( - "tenant_id", - "to_type", - "to_ref" - ); diff --git a/migrations/0007_knowledge_embed_model.sql b/migrations/0007_knowledge_embed_model.sql deleted file mode 100644 index 315dd9e..0000000 --- a/migrations/0007_knowledge_embed_model.sql +++ /dev/null @@ -1,14 +0,0 @@ --- Which embedding model is active per tenant, and the dims it was discovered --- at (never hard-coded). The per-model "knowledge"."embedding_" vector --- tables are runtime-managed by the single guarded activation path, not here. -CREATE TABLE IF NOT EXISTS "knowledge"."embed_model" ( - "id" text PRIMARY KEY, - "tenant_id" text NOT NULL, - "model_key" text NOT NULL, - "model_id" text NOT NULL, - "dims" integer NOT NULL, - "status" text NOT NULL DEFAULT 'active', - "created_at" timestamp NOT NULL DEFAULT now(), - "updated_at" timestamp NOT NULL DEFAULT now(), - CONSTRAINT "embed_model_tenant_model_key_uniq" UNIQUE ("tenant_id", "model_key") -); diff --git a/migrations/0008_raw_capture.sql b/migrations/0008_raw_capture.sql deleted file mode 100644 index d7ac68c..0000000 --- a/migrations/0008_raw_capture.sql +++ /dev/null @@ -1,27 +0,0 @@ --- The raw corpus, persisted immutably before derivation. The exact /capture --- request payload (adapter + occurred_at + document) is stored here first, in --- the same transaction as the derived document/version/chunk/edge rows, so a --- later replay can re-derive under a different config without re-fetching --- source. Append-only: rows are never updated or deleted by ingestion; dedupe --- on (tenant_id, source_hash) reuses the existing row. -CREATE TABLE IF NOT EXISTS "knowledge"."raw_capture" ( - "id" text PRIMARY KEY, - "tenant_id" text NOT NULL, - "adapter" text NOT NULL, - "external_ref" text NOT NULL, - "fetched_at" timestamp NOT NULL DEFAULT now(), - "content_type" text NOT NULL, - "raw_text" text, - "raw_bytes" bytea, - "metadata" jsonb NOT NULL DEFAULT '{}', - "source_hash" text NOT NULL -); - -CREATE UNIQUE INDEX IF NOT EXISTS "raw_capture_tenant_source_hash_uniq" - ON "knowledge"."raw_capture" ("tenant_id", "source_hash"); - -CREATE INDEX IF NOT EXISTS "raw_capture_tenant_adapter_external_ref_idx" - ON "knowledge"."raw_capture" ("tenant_id", "adapter", "external_ref"); - -ALTER TABLE "knowledge"."version" - ADD COLUMN IF NOT EXISTS "raw_capture_id" text REFERENCES "knowledge"."raw_capture"("id"); diff --git a/migrations/0009_transform_pipeline.sql b/migrations/0009_transform_pipeline.sql deleted file mode 100644 index 05bdf4b..0000000 --- a/migrations/0009_transform_pipeline.sql +++ /dev/null @@ -1,48 +0,0 @@ --- The replayable, versioned, config-driven transform pipeline. A named --- `transform_config` drives a `transform_run` that re-derives the corpus from --- the immutable `raw_capture` rows under a NEW `generation` tag, without ever --- re-fetching source or touching the 'live' generation's versions. - -ALTER TABLE "knowledge"."version" - ADD COLUMN IF NOT EXISTS "generation" text NOT NULL DEFAULT 'live'; - --- Version numbering moves from per-document to per-(document, generation), --- so a replay generation can mint its own v1 alongside the live document's --- existing versions instead of colliding with them. -DROP INDEX IF EXISTS "knowledge"."version_document_version_uniq"; - -CREATE UNIQUE INDEX IF NOT EXISTS "version_document_generation_version_uniq" - ON "knowledge"."version" ("document_id", "generation", "version"); - -CREATE TABLE IF NOT EXISTS "knowledge"."transform_config" ( - "id" text PRIMARY KEY, - "tenant_id" text NOT NULL, - "name" text NOT NULL, - "version" integer NOT NULL, - "params" jsonb NOT NULL DEFAULT '{}', - "created_at" timestamp NOT NULL DEFAULT now(), - CONSTRAINT "transform_config_tenant_name_version_uniq" UNIQUE ("tenant_id", "name", "version") -); - -CREATE TABLE IF NOT EXISTS "knowledge"."transform_run" ( - "id" text PRIMARY KEY, - "tenant_id" text NOT NULL, - "config_id" text NOT NULL REFERENCES "knowledge"."transform_config" ("id"), - "scope" jsonb NOT NULL DEFAULT '{}', - "generation" text NOT NULL, - "status" text NOT NULL DEFAULT 'running', - "raw_count" integer NOT NULL DEFAULT 0, - "version_count" integer NOT NULL DEFAULT 0, - "error" text, - "created_at" timestamp NOT NULL DEFAULT now(), - "completed_at" timestamp, - CONSTRAINT "transform_run_status_check" CHECK ("status" IN ('running', 'completed', 'failed')) -); - --- One run mints exactly one generation; resolving a generation's tuning --- config at search time is a lookup on this uniqueness. -CREATE UNIQUE INDEX IF NOT EXISTS "transform_run_generation_uniq" - ON "knowledge"."transform_run" ("generation"); - -CREATE INDEX IF NOT EXISTS "transform_run_tenant_config_idx" - ON "knowledge"."transform_run" ("tenant_id", "config_id"); diff --git a/packages/knowledge-adapter-mem0/README.md b/packages/knowledge-adapter-mem0/README.md index 62609b3..af0f188 100644 --- a/packages/knowledge-adapter-mem0/README.md +++ b/packages/knowledge-adapter-mem0/README.md @@ -53,7 +53,7 @@ mountKnowledgeEngine(app, { | Area | Behavior | | --- | --- | | Isolation | **Principal-bucket only** via `mapUser(tenantId, principalId)`. Each principal has a private Mem0 `user_id`; docs are not shared across principals. | -| Visibility ladder | `visibility` / `share` / `blockPrincipalIds` are **not** enforced by this adapter (metadata at best). For multi-principal or tenant-wide ACL, use the default pgvector store or a store that implements the ladder. | +| Document access | This adapter is **principal-bucket only**. Host grant tags (`accessTags`) are stored as metadata at best and are **not** evaluated. For multi-principal grant-tag ACL, use the default pgvector store. | | `recent` | Always `[]` — Mem0 has no recent-feed API here. | | `options.memory` | **Never** mount this package as `options.memory`. That port is an ask side-channel; Mem0 as product backend is `documentStore` only. | diff --git a/packages/knowledge-adapter-mem0/src/create-mem0-document-store.test.ts b/packages/knowledge-adapter-mem0/src/create-mem0-document-store.test.ts index 2f49024..9c9bd3e 100644 --- a/packages/knowledge-adapter-mem0/src/create-mem0-document-store.test.ts +++ b/packages/knowledge-adapter-mem0/src/create-mem0-document-store.test.ts @@ -73,7 +73,7 @@ describe("createMem0DocumentStore", () => { principalId: "p1", title: "Prefs", text: "Prefers dark mode", - visibility: { mode: "private", principalIds: ["p1"] }, + accessTags: ["knowledge.owner:p1"], }); expect(documentId).toMatch( @@ -150,7 +150,7 @@ describe("createMem0DocumentStore", () => { principalId: "p", title: "t", text: "x", - visibility: { mode: "tenant" }, + accessTags: ["knowledge.owner:p"], }), ).rejects.toThrow(/tenantId/); diff --git a/packages/knowledge-adapter-mem0/src/create-mem0-document-store.ts b/packages/knowledge-adapter-mem0/src/create-mem0-document-store.ts index a6c1da3..3afcd93 100644 --- a/packages/knowledge-adapter-mem0/src/create-mem0-document-store.ts +++ b/packages/knowledge-adapter-mem0/src/create-mem0-document-store.ts @@ -35,13 +35,15 @@ function encodeDocumentBody(params: { text: string; documentId: string; externalRef?: string; - visibilityMode: string; + accessTags: string[]; }): string { const header = `# ${params.title}`; const meta = [ `documentId: ${params.documentId}`, params.externalRef ? `externalRef: ${params.externalRef}` : null, - `visibility: ${params.visibilityMode}`, + params.accessTags.length > 0 + ? `accessTags: ${params.accessTags.join(",")}` + : null, ] .filter(Boolean) .join("\n"); @@ -211,13 +213,15 @@ export function createMem0DocumentStore( ...(params.externalRef !== undefined ? { externalRef: params.externalRef } : {}), - visibilityMode: params.visibility.mode, + accessTags: params.accessTags ?? [], }); const metadata: Record = { documentId, title: params.title, - visibility: params.visibility.mode, }; + if (params.accessTags?.length) { + metadata.accessTags = params.accessTags.join(","); + } if (params.externalRef !== undefined) { metadata.externalRef = params.externalRef; } diff --git a/packages/knowledge-adapter-mem0/src/index.ts b/packages/knowledge-adapter-mem0/src/index.ts index 625f9dd..643fddd 100644 --- a/packages/knowledge-adapter-mem0/src/index.ts +++ b/packages/knowledge-adapter-mem0/src/index.ts @@ -9,7 +9,6 @@ export type { Mem0ClientOptions, Mem0MemoryProviderOptions, MemoryProvider, - VisibilitySpec, } from "./types.ts"; export { mapUser } from "./map-user.ts"; export { createMem0DocumentStore } from "./create-mem0-document-store.ts"; diff --git a/packages/knowledge-adapter-mem0/src/types.ts b/packages/knowledge-adapter-mem0/src/types.ts index a187ce2..ad52995 100644 --- a/packages/knowledge-adapter-mem0/src/types.ts +++ b/packages/knowledge-adapter-mem0/src/types.ts @@ -15,20 +15,17 @@ export type DocumentStoreCitation = { }; }; -export type VisibilitySpec = - | { mode: "tenant" } - | { mode: "private"; principalIds: string[] } - | { mode: "principals"; principalIds: string[] }; - export type DocumentStoreAddParams = { tenantId: string; principalId: string; title: string; text: string; - visibility: VisibilitySpec; - blockPrincipalIds?: string[]; + /** Grant-tag resource strings (ignored for enforcement; principal-bucket only). */ + accessTags: string[]; attributes?: Record; externalRef?: string; + adapter?: string; + kind?: string; }; export type DocumentStoreFindParams = { diff --git a/packages/knowledge-adapter-supermemory/README.md b/packages/knowledge-adapter-supermemory/README.md index 9c4a19b..1f52a8d 100644 --- a/packages/knowledge-adapter-supermemory/README.md +++ b/packages/knowledge-adapter-supermemory/README.md @@ -43,7 +43,7 @@ plane (add/find/ask), not memories-only personal facts. | Area | Behavior | | --- | --- | | Isolation | **Principal-bucket only** via `containerTag(tenantId, principalId)`. Each principal has a private container; docs are not shared across principals. | -| Visibility ladder | `visibility` / `share` / `blockPrincipalIds` are **not** enforced by this adapter. For multi-principal or tenant-wide ACL, use the default pgvector store or a store that implements the ladder. | +| Document access | This adapter is **principal-bucket only**. Host grant tags (`accessTags`) are stored as metadata at best and are **not** evaluated. For multi-principal grant-tag ACL, use the default pgvector store. | | `recent` | Always `[]` — no recent-feed API in this adapter. | | `options.memory` | **Never** mount this package as `options.memory`. Product backend is `documentStore` only. | diff --git a/packages/knowledge-adapter-supermemory/src/index.test.ts b/packages/knowledge-adapter-supermemory/src/index.test.ts index d736792..52a0bf8 100644 --- a/packages/knowledge-adapter-supermemory/src/index.test.ts +++ b/packages/knowledge-adapter-supermemory/src/index.test.ts @@ -57,7 +57,7 @@ describe("createSupermemoryDocumentStore", () => { principalId: "alice", title: "t", text: "hello", - visibility: { mode: "tenant" }, + accessTags: ["knowledge.owner:alice"], }), ).rejects.toThrow(/non-empty/); @@ -92,7 +92,7 @@ describe("createSupermemoryDocumentStore", () => { principalId: "alice", title: "Prefs", text: "prefers dark mode", - visibility: { mode: "private", principalIds: ["alice"] }, + accessTags: ["knowledge.owner:alice"], }); expect(documentId).toMatch( @@ -167,14 +167,14 @@ describe("createSupermemoryDocumentStore", () => { principalId: "user-1", title: "a", text: "fact a", - visibility: { mode: "tenant" }, + accessTags: ["knowledge.owner:user-1"], }); await store.add({ tenantId: "tenant-b", principalId: "user-1", title: "b", text: "fact b", - visibility: { mode: "tenant" }, + accessTags: ["knowledge.owner:user-1"], }); expect(tags).toEqual(["t8_tenant-a_u6_user-1", "t8_tenant-b_u6_user-1"]); diff --git a/packages/knowledge-adapter-supermemory/src/index.ts b/packages/knowledge-adapter-supermemory/src/index.ts index 5c67079..9b14775 100644 --- a/packages/knowledge-adapter-supermemory/src/index.ts +++ b/packages/knowledge-adapter-supermemory/src/index.ts @@ -19,20 +19,17 @@ export type DocumentStoreCitation = { }; }; -export type VisibilitySpec = - | { mode: "tenant" } - | { mode: "private"; principalIds: string[] } - | { mode: "principals"; principalIds: string[] }; - export type DocumentStoreAddParams = { tenantId: string; principalId: string; title: string; text: string; - visibility: VisibilitySpec; - blockPrincipalIds?: string[]; + /** Grant-tag resource strings (ignored for enforcement; principal-bucket only). */ + accessTags: string[]; attributes?: Record; externalRef?: string; + adapter?: string; + kind?: string; }; export type DocumentStoreFindParams = { @@ -171,13 +168,15 @@ function encodeContent(params: { text: string; documentId: string; externalRef?: string; - visibilityMode: string; + accessTags: string[]; }): string { const header = `# ${params.title}`; const meta = [ `documentId: ${params.documentId}`, params.externalRef ? `externalRef: ${params.externalRef}` : null, - `visibility: ${params.visibilityMode}`, + params.accessTags.length > 0 + ? `accessTags: ${params.accessTags.join(",")}` + : null, ] .filter(Boolean) .join("\n"); @@ -243,13 +242,15 @@ export function createSupermemoryDocumentStore( ...(params.externalRef !== undefined ? { externalRef: params.externalRef } : {}), - visibilityMode: params.visibility.mode, + accessTags: params.accessTags ?? [], }); const metadata: Record = { documentId, title: params.title, - visibility: params.visibility.mode, }; + if (params.accessTags?.length) { + metadata.accessTags = params.accessTags.join(","); + } if (params.externalRef !== undefined) { metadata.externalRef = params.externalRef; } diff --git a/src/acl.test.ts b/src/acl.test.ts index ed4d0bf..a85dac4 100644 --- a/src/acl.test.ts +++ b/src/acl.test.ts @@ -1,193 +1,143 @@ import { describe, expect, test } from "bun:test"; - -import { blockedDocumentIds, parseAcl, readBlockList } from "./acl.ts"; - -describe("parseAcl", () => { - test("default is scope/tenant", () => { - const r = parseAcl(undefined, "u1"); - expect(r.ok).toBe(true); - if (r.ok) expect(r.visibility).toEqual({ mode: "tenant" }); - }); - - test("private pins subject", () => { - const r = parseAcl({ mode: "private" }, "u1"); - expect(r.ok).toBe(true); - if (r.ok) { - expect(r.visibility).toEqual({ mode: "private", principalIds: ["u1"] }); - } - }); - - test("allowlist always includes subject", () => { - const r = parseAcl({ mode: "allowlist", allow: ["other"] }, "u1"); - expect(r.ok).toBe(true); - if (r.ok) { - expect(r.visibility.mode).toBe("principals"); - expect(r.visibility.principalIds).toContain("u1"); - expect(r.visibility.principalIds).toContain("other"); - } +import { createInMemoryGrantStore } from "@intx/authz"; + +import { + canAccessDocument, + filterAccessibleDocuments, + ownerTag, + resolveAccessTags, + tenantTag, +} from "./acl.ts"; + +describe("resolveAccessTags", () => { + test("always includes owner tag", () => { + expect(resolveAccessTags({ principalId: "u1", tenantId: "t1" })).toEqual([ + ownerTag("u1"), + ]); + }); + + test("share.tenant mints tenant tag", () => { + const tags = resolveAccessTags({ + principalId: "u1", + tenantId: "t1", + share: { tenant: true }, + }); + expect(tags).toContain(ownerTag("u1")); + expect(tags).toContain(tenantTag("t1")); }); - test("nested { subjects } allow/block shape", () => { - const r = parseAcl( - { - mode: "allowlist", - allow: { subjects: ["alice"] }, - block: { subjects: ["bob"] }, - }, - "u1", - ); - expect(r.ok).toBe(true); - if (r.ok) { - expect(r.visibility.mode).toBe("principals"); - expect(r.visibility.principalIds).toEqual( - expect.arrayContaining(["u1", "alice"]), - ); - expect(r.block).toEqual(["bob"]); - } + test("share.principals mints peer owner tags", () => { + const tags = resolveAccessTags({ + principalId: "u1", + tenantId: "t1", + share: { principals: ["alice", "bob"] }, + }); + expect(tags).toContain(ownerTag("u1")); + expect(tags).toContain(ownerTag("alice")); + expect(tags).toContain(ownerTag("bob")); }); - test("rejects groups/grants until membership lands", () => { - const r = parseAcl( - { mode: "allowlist", allow: { subjects: ["a"], groups: ["eng"] } }, - "u1", + test("share.tags and explicit accessTags merge", () => { + const tags = resolveAccessTags({ + principalId: "u1", + tenantId: "t1", + accessTags: ["knowledge.space:eng"], + share: { tags: ["knowledge.project:ke"] }, + }); + expect(tags).toEqual( + expect.arrayContaining([ + ownerTag("u1"), + "knowledge.space:eng", + "knowledge.project:ke", + ]), ); - expect(r.ok).toBe(false); - if (!r.ok) expect(r.error).toMatch(/groups/); }); }); -describe("readBlockList", () => { - test("reads the JSON-string encoding capture() writes", () => { - expect(readBlockList(JSON.stringify(["p1", "p2"]))).toEqual({ - kind: "list", - principalIds: ["p1", "p2"], +describe("canAccessDocument", () => { + test("creator always allowed without grants on tags", async () => { + const grants = createInMemoryGrantStore([]); + const ok = await canAccessDocument({ + grants, + tenantId: "t1", + principalId: "u1", + createdByPrincipalId: "u1", + accessTags: [], }); + expect(ok).toBe(true); }); - test("reads a native array, which is what jsonb holds naturally", () => { - // A seed script, migration or other service writing the column directly - // has no reason to double-encode. Whether a block list is honoured must - // not depend on which writer produced it. - expect(readBlockList(["p1"])).toEqual({ - kind: "list", - principalIds: ["p1"], + test("peer needs grant on a tag", async () => { + const grants = createInMemoryGrantStore([ + { + id: "g1", + principalId: "peer", + resource: tenantTag("t1"), + action: "find", + effect: "allow", + origin: "role", + conditions: null, + expiresAt: null, + roleId: null, + }, + ]); + const denied = await canAccessDocument({ + grants: createInMemoryGrantStore([]), + tenantId: "t1", + principalId: "peer", + createdByPrincipalId: "owner", + accessTags: [tenantTag("t1")], }); - }); - - test("absent, null and empty are absent, not blocked", () => { - expect(readBlockList(undefined)).toEqual({ kind: "absent" }); - expect(readBlockList(null)).toEqual({ kind: "absent" }); - expect(readBlockList("")).toEqual({ kind: "absent" }); - }); - - test("whitespace-only string is absent, not unreadable", () => { - // Blank storage is nothing rather than a list we failed to read — same - // as empty string. A pure-whitespace value is not a present ACL. - expect(readBlockList(" ")).toEqual({ kind: "absent" }); - expect(readBlockList("\t\n")).toEqual({ kind: "absent" }); - }); - - test("an empty list blocks nobody but is still a list", () => { - expect(readBlockList([])).toEqual({ kind: "list", principalIds: [] }); - expect(readBlockList("[]")).toEqual({ kind: "list", principalIds: [] }); - }); - - test("unparseable string is unreadable", () => { - expect(readBlockList("{not json")).toEqual({ kind: "unreadable" }); - }); - - test("a present value that is not a list of ids is unreadable", () => { - // The regression this exists for: these once skipped the block check - // entirely, so a document with an ACL nobody could read was returned. - const notLists: unknown[] = [ - 42, - true, - false, - 0, - { subjects: ["p1"] }, - '"p1"', - "null", - " x", - ["p1", 7], - ["p1", null], - [["p1"]], - [{}], - ]; - for (const raw of notLists) { - expect({ raw, read: readBlockList(raw) }).toEqual({ - raw, - read: { kind: "unreadable" }, - }); - } + expect(denied).toBe(false); + + const allowed = await canAccessDocument({ + grants, + tenantId: "t1", + principalId: "peer", + createdByPrincipalId: "owner", + accessTags: [tenantTag("t1")], + }); + expect(allowed).toBe(true); }); }); -describe("blockedDocumentIds", () => { - const row = (id: string, aclBlock?: unknown) => ({ - id, - attributes: aclBlock === undefined ? {} : { acl_block: aclBlock }, - }); - - test("blocks a principal named in the list", () => { - const { blocked } = blockedDocumentIds(["d1"], [row("d1", ["p1"])], "p1"); - expect([...blocked]).toEqual(["d1"]); - }); - - test("leaves a document alone when the principal is not named", () => { - const { blocked } = blockedDocumentIds(["d1"], [row("d1", ["p2"])], "p1"); - expect([...blocked]).toEqual([]); - }); - - test("withholds a document whose acl_block cannot be read", () => { - // The consequence the fix exists for: an unreadable ACL must remove the - // document, not merely be classified as unreadable. - const { blocked, unreadable } = blockedDocumentIds( - ["d1"], - [row("d1", 42)], - "p1", - ); - expect([...blocked]).toEqual(["d1"]); - expect(unreadable).toEqual(["d1"]); - }); - - test("withholds a searched document whose row did not come back", () => { - // Deleted between the search and the post-filter. We cannot evaluate its - // ACL, so it must not be returned. - const { blocked, unreadable } = blockedDocumentIds(["d1", "d2"], [row("d1")], "p1"); - expect([...blocked]).toEqual(["d2"]); - expect(unreadable).toEqual(["d2"]); - }); - - test("does not withhold documents with no acl_block at all", () => { - const { blocked, unreadable } = blockedDocumentIds( - ["d1", "d2"], - [row("d1"), row("d2", null)], - "p1", - ); - expect([...blocked]).toEqual([]); - expect(unreadable).toEqual([]); - }); - - test("a row whose attributes column is null is absent, not unreadable", () => { - // attributes is jsonb NOT NULL DEFAULT '{}' in schema, but the reader - // still accepts null rows so a soft-schema drift cannot fail closed on - // every document. - const { blocked, unreadable } = blockedDocumentIds( - ["d1"], - [{ id: "d1", attributes: null }], - "p1", - ); - expect([...blocked]).toEqual([]); - expect(unreadable).toEqual([]); - }); - - test("honours a block written as a native array, not only a JSON string", () => { - const viaString = blockedDocumentIds( - ["d1"], - [row("d1", JSON.stringify(["p1"]))], - "p1", - ); - const viaArray = blockedDocumentIds(["d1"], [row("d1", ["p1"])], "p1"); - expect([...viaString.blocked]).toEqual([...viaArray.blocked]); +describe("filterAccessibleDocuments", () => { + test("keeps creator and granted docs only", async () => { + const grants = createInMemoryGrantStore([ + { + id: "g1", + principalId: "viewer", + resource: "knowledge.space:eng", + action: "find", + effect: "allow", + origin: "role", + conditions: null, + expiresAt: null, + roleId: null, + }, + ]); + const docs = [ + { + id: "a", + createdByPrincipalId: "viewer", + accessTags: [ownerTag("viewer")], + }, + { + id: "b", + createdByPrincipalId: "other", + accessTags: ["knowledge.space:eng"], + }, + { + id: "c", + createdByPrincipalId: "other", + accessTags: [ownerTag("other")], + }, + ]; + const out = await filterAccessibleDocuments(docs, { + grants, + tenantId: "t1", + principalId: "viewer", + }); + expect(out.map((d) => d.id)).toEqual(["a", "b"]); }); }); diff --git a/src/acl.ts b/src/acl.ts index 27487d2..3563bf9 100644 --- a/src/acl.ts +++ b/src/acl.ts @@ -1,198 +1,148 @@ /** - * Product document ACL → engine VisibilitySpec. + * Document access via Interchange authz grant tags — not a mini-ACL. * - * Product modes (PRODUCT.md): - * scope — whole team (engine: tenant) - * private — subject only (engine: private) - * allowlist — named principals (engine: principals) + * Spec: docs/AUTHZ-DOCUMENT-ACCESS.md * - * allow/block may be flat string[] or { subjects?: string[] } (PRODUCT.md shape). - * groups/grants on ACL are rejected until membership resolution lands — do not - * silently drop them (that would look like they applied). - * Block is stored on the document and applied as a search post-filter. + * - Capability (knowledge:add / knowledge:find) is checked elsewhere. + * - Document access: creator always sees own docs; otherwise any accessTag + * that authorize(…, tag, "find") allows. + * - Share sugars only mint tags. + * + * Hard cutover: no visibility modes, no block lists, no dual-read ACL path. */ -import type { VisibilitySpec } from "./core/schemas/document.ts"; - -export type AclMode = "scope" | "private" | "allowlist"; +import { authorize } from "@intx/authz"; +import type { ConditionRegistry, GrantStore } from "@intx/authz"; -export type AclParseResult = - | { ok: true; visibility: VisibilitySpec; block: string[] } - | { ok: false; error: string }; - -function rejectUnsupportedAclFields( - raw: unknown, - field: string, -): string | null { - if (raw === undefined || raw === null || Array.isArray(raw)) return null; - if (typeof raw !== "object") return null; - const o = raw as Record; - if (o.groups !== undefined) { - return `${field}.groups is not supported yet; list subjects only, or omit groups`; - } - if (o.grants !== undefined) { - return `${field}.grants is not supported yet; list subjects only, or omit grants`; - } - return null; +export function ownerTag(principalId: string): string { + return `knowledge.owner:${principalId}`; } -function subjectList(raw: unknown, field: string): string[] | { error: string } { - if (raw === undefined || raw === null) return []; - if (Array.isArray(raw)) { - if (!raw.every((x) => typeof x === "string")) { - return { error: `${field} must be an array of strings` }; - } - return raw as string[]; - } - if (typeof raw === "object") { - const unsupported = rejectUnsupportedAclFields(raw, field); - if (unsupported) return { error: unsupported }; - const o = raw as Record; - const subjects = o.subjects; - if (subjects === undefined) return []; - if (!Array.isArray(subjects) || !subjects.every((x) => typeof x === "string")) { - return { error: `${field}.subjects must be an array of strings` }; - } - return subjects as string[]; - } - return { error: `${field} must be an array or { subjects?: string[] }` }; +export function tenantTag(tenantId: string): string { + return `knowledge.tenant:${tenantId}`; } -export function parseAcl( - raw: unknown, - subjectId: string, -): AclParseResult { - if (raw === undefined || raw === null) { - return { - ok: true, - visibility: { mode: "tenant" }, - block: [], - }; - } - if (typeof raw !== "object" || Array.isArray(raw)) { - return { ok: false, error: "acl must be an object" }; - } - const body = raw as Record; - const mode = body.mode; - if ( - mode !== "scope" && - mode !== "private" && - mode !== "allowlist" && - mode !== "tenant" - ) { - return { - ok: false, - error: "acl.mode must be scope, tenant, private, or allowlist", - }; - } +/** Share sugar — maps only to tags (no visibility modes / block lists). */ +export type ShareSugar = { + /** Include knowledge.tenant: */ + tenant?: boolean; + /** Include knowledge.owner: for each peer */ + principals?: string[]; + /** Explicit resource tags (host grant space) */ + tags?: string[]; +}; + +export type ResolveAccessTagsParams = { + principalId: string; + tenantId: string; + /** Explicit tags from the caller (merged with defaults/share). */ + accessTags?: string[]; + share?: ShareSugar; +}; - const blockParsed = subjectList(body.block, "acl.block"); - if ("error" in blockParsed) return { ok: false, error: blockParsed.error }; - const block = blockParsed; +/** + * Resolve the tag set written on add. + * Always includes knowledge.owner:. Never invents visibility modes. + */ +export function resolveAccessTags(params: ResolveAccessTagsParams): string[] { + const tags = new Set(); + tags.add(ownerTag(params.principalId)); - if (mode === "scope" || mode === "tenant") { - return { ok: true, visibility: { mode: "tenant" }, block }; + if (params.accessTags) { + for (const t of params.accessTags) { + if (typeof t === "string" && t.trim() !== "") tags.add(t.trim()); + } } - if (mode === "private") { - return { - ok: true, - visibility: { mode: "private", principalIds: [subjectId] }, - block, - }; + const share = params.share; + if (share) { + if (share.tenant) tags.add(tenantTag(params.tenantId)); + if (share.principals) { + for (const p of share.principals) { + if (typeof p === "string" && p.trim() !== "") { + tags.add(ownerTag(p.trim())); + } + } + } + if (share.tags) { + for (const t of share.tags) { + if (typeof t === "string" && t.trim() !== "") tags.add(t.trim()); + } + } } - const allowParsed = subjectList(body.allow, "acl.allow"); - if ("error" in allowParsed) return { ok: false, error: allowParsed.error }; - const principalIds = Array.from(new Set([subjectId, ...allowParsed])); - return { - ok: true, - visibility: { mode: "principals", principalIds }, - block, - }; + return [...tags]; } -/** - * What a stored `acl_block` value turned out to be. - * - * `unreadable` is deliberately distinct from `absent`: a value that is present - * but not a list of principal ids tells us an ACL was intended and that we do - * not understand it. Callers must block on it — guessing there means guessing - * in the direction of disclosure. - */ -export type BlockListRead = - | { kind: "absent" } - | { kind: "unreadable" } - | { kind: "list"; principalIds: string[] }; +export type CanAccessDocumentParams = { + grants: GrantStore; + tenantId: string; + principalId: string; + createdByPrincipalId: string | null | undefined; + accessTags: readonly string[]; + conditionRegistry?: ConditionRegistry; +}; /** - * Reads a stored `acl_block` value. - * - * Both encodings are accepted. `capture()` writes a JSON-encoded string, but - * `attributes` is `jsonb` and a native array is the natural shape for anything - * writing the column directly — a seed script, a migration, another service. - * Whether a block list is honoured must not depend on which writer produced it. + * True when the principal may see this document under grant-tag rules. + * Creator is always allowed (implicit owner). Everyone else needs an allow + * on at least one accessTag for action "find". */ -export function readBlockList(raw: unknown): BlockListRead { - if (raw === undefined || raw === null) return { kind: "absent" }; - - let parsed: unknown = raw; - if (typeof raw === "string") { - // A blank value is nothing rather than a list we failed to read. - if (raw.trim() === "") return { kind: "absent" }; - try { - parsed = JSON.parse(raw); - } catch { - return { kind: "unreadable" }; - } +export async function canAccessDocument( + params: CanAccessDocumentParams, +): Promise { + if ( + params.createdByPrincipalId != null && + params.createdByPrincipalId !== "" && + params.createdByPrincipalId === params.principalId + ) { + return true; } - if (!Array.isArray(parsed)) return { kind: "unreadable" }; - // Indexed rather than `every`, which skips holes in a sparse array and would - // let `["a", , "b"]` through as string[] carrying an undefined. - const principalIds: string[] = []; - for (let i = 0; i < parsed.length; i++) { - const entry: unknown = parsed[i]; - if (typeof entry !== "string") return { kind: "unreadable" }; - principalIds.push(entry); + for (const tag of params.accessTags) { + if (!tag) continue; + const decision = await authorize( + params.grants, + params.principalId, + params.tenantId, + tag, + "find", + params.conditionRegistry, + ); + if (decision.effect === "allow") return true; } - return { kind: "list", principalIds }; + return false; } /** - * Decides which of the searched documents this principal may not see. - * - * Takes the ids that were searched alongside the rows that came back, because - * a document that could not be loaded must be withheld too. Resolving either - * kind of missing information — an ACL we cannot read, or a row we cannot find - * — in the direction of disclosure is the failure this guards against. + * Filter a list of docs to those the principal may see. */ -export function blockedDocumentIds( - documentIds: readonly string[], - rows: readonly { id: string; attributes: Record | null }[], - principalId: string, -): { blocked: Set; unreadable: string[] } { - const byId = new Map(rows.map((row) => [row.id, row])); - const blocked = new Set(); - const unreadable: string[] = []; - - for (const id of documentIds) { - const row = byId.get(id); - if (row === undefined) { - // Searched but not loaded: deleted mid-search, or a read that raced a - // write. We cannot evaluate its ACL, so we do not return it. - unreadable.push(id); - blocked.add(id); - continue; - } - const read = readBlockList(row.attributes?.["acl_block"]); - if (read.kind === "absent") continue; - if (read.kind === "unreadable") { - unreadable.push(id); - blocked.add(id); - continue; - } - if (read.principalIds.includes(principalId)) blocked.add(id); +export async function filterAccessibleDocuments< + T extends { + accessTags?: readonly string[] | null; + createdByPrincipalId?: string | null; + }, +>( + docs: readonly T[], + params: { + grants: GrantStore; + tenantId: string; + principalId: string; + conditionRegistry?: ConditionRegistry; + }, +): Promise { + const out: T[] = []; + for (const doc of docs) { + const ok = await canAccessDocument({ + grants: params.grants, + tenantId: params.tenantId, + principalId: params.principalId, + createdByPrincipalId: doc.createdByPrincipalId, + accessTags: doc.accessTags ?? [], + ...(params.conditionRegistry !== undefined + ? { conditionRegistry: params.conditionRegistry } + : {}), + }); + if (ok) out.push(doc); } - - return { blocked, unreadable }; + return out; } diff --git a/src/core/adapt-and-plan.test.ts b/src/core/adapt-and-plan.test.ts index 8cb1a96..6911740 100644 --- a/src/core/adapt-and-plan.test.ts +++ b/src/core/adapt-and-plan.test.ts @@ -9,7 +9,7 @@ function validAdaptedDocument( kind: "artifact", title: "Q3 renewal brief", externalRef: "artifact:art_1", - visibility: { mode: "principals", principalIds: ["principal_1"] }, + accessTags: ["knowledge.owner:principal_1"], entityHints: [], chunks: [ { ordinal: 0, text: "The account renews in Q3 with a 12% expansion." }, diff --git a/src/core/fts-language.test.ts b/src/core/fts-language.test.ts index 033731d..81242e5 100644 --- a/src/core/fts-language.test.ts +++ b/src/core/fts-language.test.ts @@ -29,10 +29,10 @@ describe("parseFtsLanguage", () => { }); }); -describe("the chunk migration language token", () => { +describe("the baseline migration language token", () => { it("is present in the generated-column DDL, ready for substitution", async () => { const ddl = await readFile( - join(import.meta.dir, "..", "..", "migrations", "0004_knowledge_chunk.sql"), + join(import.meta.dir, "..", "..", "migrations", "0002_knowledge_baseline.sql"), "utf8", ); expect(ddl).toContain(`to_tsvector('${FTS_LANGUAGE_TOKEN}', "text")`); diff --git a/src/core/schemas/adapted-document.test.ts b/src/core/schemas/adapted-document.test.ts index eb9c006..fa8498c 100644 --- a/src/core/schemas/adapted-document.test.ts +++ b/src/core/schemas/adapted-document.test.ts @@ -10,7 +10,7 @@ describe("AdaptedDocumentSchema", () => { kind: "call_transcript", title: "Q3 renewal call", externalRef: "granola:note_123", - visibility: { mode: "tenant" }, + accessTags: ["knowledge.tenant:t1"], attributes: { durationSec: 1800 }, entityHints: [{ kind: "person", identifier: "jane@example.com" }], edges: [ @@ -34,7 +34,7 @@ describe("AdaptedDocumentSchema", () => { kind: "task", title: "Follow up with Acme", externalRef: "task:1", - visibility: { mode: "private" }, + accessTags: ["knowledge.owner:u1"], entityHints: [], chunks: [{ ordinal: 0, text: "Follow up with Acme on pricing." }], contentHash: "sha256:def456", @@ -47,7 +47,7 @@ describe("AdaptedDocumentSchema", () => { kind: "task", title: "Follow up with Acme", externalRef: "task:1", - visibility: { mode: "private" }, + accessTags: ["knowledge.owner:u1"], entityHints: [], chunks: [{ ordinal: 0, text: "Follow up with Acme on pricing." }], }); diff --git a/src/core/schemas/adapted-document.ts b/src/core/schemas/adapted-document.ts index fa18b70..00f0071 100644 --- a/src/core/schemas/adapted-document.ts +++ b/src/core/schemas/adapted-document.ts @@ -1,5 +1,5 @@ import { type } from "arktype"; -import { CreatedByKindSchema, VisibilitySpecSchema } from "./document.ts"; +import { CreatedByKindSchema } from "./document.ts"; import { KnowledgeEdgeHintSchema } from "./entity-edge.ts"; import { AuthoritySourceClassSchema } from "../authority.ts"; @@ -45,11 +45,15 @@ export type RawPointer = typeof RawPointerSchema.infer; // What a source adapter produces. rawPointer is a pointer to the raw // payload's own table, never a re-store of the blob itself; contentHash is // the NOOP key. +// +// Document access is grant tags only (`accessTags`) — the security boundary +// (docs/AUTHZ-DOCUMENT-ACCESS.md). export const AdaptedDocumentSchema = type({ kind: `1 <= string <= ${MAX_KIND_CHARS}`, title: `1 <= string <= ${MAX_TITLE_CHARS}`, externalRef: "string", - visibility: VisibilitySpecSchema, + /** Grant-tag resource strings — the security boundary. */ + accessTags: "string[]", "attributes?": "Record", entityHints: EntityHintSchema.array(), "edges?": KnowledgeEdgeHintSchema.array(), diff --git a/src/core/schemas/document.test.ts b/src/core/schemas/document.test.ts index 90ba8ce..290f6bb 100644 --- a/src/core/schemas/document.test.ts +++ b/src/core/schemas/document.test.ts @@ -3,24 +3,9 @@ import { type } from "arktype"; import { KnowledgeDocumentSchema, KnowledgeVersionSchema, - VisibilitySpecSchema, } from "./document.ts"; import type { KnowledgeDocument, KnowledgeVersion } from "./document.ts"; -describe("VisibilitySpecSchema", () => { - it("parses a tenant-mode spec with no optional fields", () => { - const out = VisibilitySpecSchema({ mode: "tenant" }); - expect(out instanceof type.errors ? out.summary : out).toEqual({ - mode: "tenant", - }); - }); - - it("rejects an unknown mode", () => { - const out = VisibilitySpecSchema({ mode: "public" }); - expect(out instanceof type.errors).toBe(true); - }); -}); - describe("KnowledgeDocumentSchema", () => { it("round-trips a full fixture", () => { const fixture: KnowledgeDocument = { @@ -30,7 +15,7 @@ describe("KnowledgeDocumentSchema", () => { title: "Q3 renewal call", adapter: "granola", external_ref: "granola:note_123", - visibility: { mode: "tenant" }, + access_tags: ["knowledge.tenant:tenant_1"], attributes: { channel: "call", pinned: true, score: null }, created_at: "2026-07-19T00:00:00.000Z", last_seen_at: "2026-07-19T00:00:00.000Z", @@ -46,7 +31,7 @@ describe("KnowledgeDocumentSchema", () => { kind: "call_transcript", title: "Q3 renewal call", adapter: "granola", - visibility: { mode: "tenant" }, + access_tags: ["knowledge.tenant:tenant_1"], attributes: {}, created_at: "2026-07-19T00:00:00.000Z", last_seen_at: "2026-07-19T00:00:00.000Z", diff --git a/src/core/schemas/document.ts b/src/core/schemas/document.ts index 63f8fbc..635421d 100644 --- a/src/core/schemas/document.ts +++ b/src/core/schemas/document.ts @@ -1,17 +1,5 @@ import { type } from "arktype"; -export const VisibilityModeSchema = type( - "'tenant'|'principals'|'source_acl'|'private'", -); -export type VisibilityMode = typeof VisibilityModeSchema.infer; - -export const VisibilitySpecSchema = type({ - mode: VisibilityModeSchema, - "principalIds?": "string[]", - "sourceAcl?": "string[]", -}); -export type VisibilitySpec = typeof VisibilitySpecSchema.infer; - export const KnowledgeVersionStatusSchema = type( "'active'|'superseded'|'deprecated'|'archived'|'tombstoned'", ); @@ -21,7 +9,7 @@ export const CreatedByKindSchema = type("'human'|'agent'|'system'|'adapter'"); export type CreatedByKind = typeof CreatedByKindSchema.infer; // The stable logical row for a captured source, deduped on (tenant_id, -// adapter, external_ref). +// adapter, external_ref). Document access is grant tags only. export const KnowledgeDocumentSchema = type({ id: "string", tenant_id: "string", @@ -29,7 +17,7 @@ export const KnowledgeDocumentSchema = type({ title: "string", adapter: "string", external_ref: "string", - visibility: VisibilitySpecSchema, + access_tags: "string[]", attributes: "Record", created_at: "string", last_seen_at: "string", diff --git a/src/db/schema.ts b/src/db/schema.ts index b967f29..d4dde48 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -33,9 +33,9 @@ export const knowledgeDocument = knowledgeSchema.table( title: text("title").notNull(), adapter: text("adapter").notNull(), externalRef: text("external_ref").notNull(), - visibilityMode: text("visibility_mode").notNull(), - visibilityPrincipalIds: jsonb("visibility_principal_ids"), - visibilitySourceAcl: jsonb("visibility_source_acl"), + // Grant-tag authz. Security path for document access. + // Resource strings in grant-pattern space; see docs/AUTHZ-DOCUMENT-ACCESS.md. + accessTags: text("access_tags").array().notNull().default([]), attributes: jsonb("attributes").notNull().default({}), createdAt: timestamp("created_at").notNull().defaultNow(), lastSeenAt: timestamp("last_seen_at").notNull().defaultNow(), diff --git a/src/index.ts b/src/index.ts index dcf43ba..2ec2e7b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -65,7 +65,6 @@ export type { SearchHit, TextExtractor, TimelineEvent, - VisibilitySpec, } from "./knowledge.ts"; export { KnowledgeError, KnowledgeNotPermittedError } from "./knowledge.ts"; // Ports — pluggable storage, live sources, and optional memory diff --git a/src/knowledge.test.ts b/src/knowledge.test.ts index 51d150d..977b5de 100644 --- a/src/knowledge.test.ts +++ b/src/knowledge.test.ts @@ -1,12 +1,11 @@ /** - * Plane construction, ACL wiring, and ask() coverage for knowledge.ts. + * Plane construction, grant-tag ACL wiring, and ask() coverage for knowledge.ts. * * - Construction: rerank maxDocChars validation runs in createKnowledgePlane. - * - Find wiring: acl.test.ts covers blockedDocumentIds itself; the post-filter - * call site is pinned here so deleting or inverting it fails the suite. + * - Find wiring: grant-tag post-filter (creator-only without grants). * - ask(): grant check, missing generate (501 before find), allow path, * synthesizeAnswer grounding. - * - add(): documentId return, content/file XOR, share → ACL mapping. + * - add(): documentId return, content/file XOR, share → access tags. * - find/recent: limit bounds, evidence default omit. */ import { @@ -43,7 +42,8 @@ const TENANT = "t1"; type DocAclRow = { id: string; - attributes: { acl_block?: unknown }; + access_tags: string[] | null; + created_by: string | null; }; /** Satisfies createFtsVerification → createRawSqlClient(sql).unsafe on the find path. */ @@ -187,10 +187,10 @@ describe("createKnowledgePlane — construction validation", () => { }); }); -describe("createKnowledgePlane.find — ACL post-filter wiring", () => { +describe("createKnowledgePlane.find — grant-tag post-filter wiring", () => { const hybridSearch = mock((): Promise => Promise.resolve({ - hits: [hit("d-blocked"), hit("d-open")], + hits: [hit("d-other"), hit("d-mine")], evidence: "strong", }), ); @@ -199,12 +199,14 @@ describe("createKnowledgePlane.find — ACL post-filter wiring", () => { mock((): Promise => Promise.resolve([ { - id: "d-blocked", - attributes: { acl_block: [PRINCIPAL] }, + id: "d-other", + access_tags: ["knowledge.owner:other"], + created_by: "other", }, { - id: "d-open", - attributes: {}, + id: "d-mine", + access_tags: [`knowledge.owner:${PRINCIPAL}`], + created_by: PRINCIPAL, }, ]), ), @@ -230,13 +232,12 @@ describe("createKnowledgePlane.find — ACL post-filter wiring", () => { mock.module("./services/search.ts", () => realSearch); }); - it("drops hits that blockedDocumentIds withholds (call-site coverage)", async () => { - // If knowledge.ts stops calling blockedDocumentIds (or keeps the blocked set - // instead of filtering it out), this assertion fails. + it("keeps creator docs and drops others when grants are absent", async () => { + // Without grants the engine store is creator-only (safe default). hybridSearch.mockClear(); hybridSearch.mockImplementation(() => Promise.resolve({ - hits: [hit("d-blocked"), hit("d-open")], + hits: [hit("d-other"), hit("d-mine")], evidence: "strong" as const, }), ); @@ -244,12 +245,14 @@ describe("createKnowledgePlane.find — ACL post-filter wiring", () => { sql.mockImplementation(() => Promise.resolve([ { - id: "d-blocked", - attributes: { acl_block: [PRINCIPAL] }, + id: "d-other", + access_tags: ["knowledge.owner:other"], + created_by: "other", }, { - id: "d-open", - attributes: {}, + id: "d-mine", + access_tags: [`knowledge.owner:${PRINCIPAL}`], + created_by: PRINCIPAL, }, ]), ); @@ -267,7 +270,7 @@ describe("createKnowledgePlane.find — ACL post-filter wiring", () => { expect(hybridSearch).toHaveBeenCalled(); expect(result.items.map((i: FindItem) => i.documentId)).toEqual([ - "d-open", + "d-mine", ]); expect(result.evidence).toBe("strong"); @@ -307,25 +310,16 @@ describe("createKnowledgePlane.find — ACL post-filter wiring", () => { await plane.close(); }); - it("withholds a hit whose acl_block is unreadable (fail-closed wiring)", async () => { - // Non-string/non-array acl_block is the case this PR closed: the post-filter - // must remove the hit, not pass it through. + it("withholds a hit whose row did not come back (fail-closed)", async () => { hybridSearch.mockClear(); hybridSearch.mockImplementation(() => Promise.resolve({ - hits: [hit("d-bad")], + hits: [hit("d-missing")], evidence: "strong" as const, }), ); sql.mockClear(); - sql.mockImplementation(() => - Promise.resolve([ - { - id: "d-bad", - attributes: { acl_block: 42 }, - }, - ]), - ); + sql.mockImplementation(() => Promise.resolve([])); const { createKnowledgePlane: makePlane } = await import( `./knowledge.ts?wiring-unreadable=${Date.now()}` @@ -355,7 +349,13 @@ describe("createKnowledgePlane.find — ACL post-filter wiring", () => { ); sql.mockClear(); sql.mockImplementation(() => - Promise.resolve([{ id: "d-open", attributes: {} }]), + Promise.resolve([ + { + id: "d-open", + access_tags: [`knowledge.owner:${PRINCIPAL}`], + created_by: PRINCIPAL, + }, + ]), ); const { createKnowledgePlane: makePlane } = await import( @@ -629,7 +629,7 @@ async function freshPlane(opts?: { await plane.close(); }); - it("maps share private to visibility private with owner principalId", async () => { + it("maps share.tenant to tenant access tag", async () => { captureDocument.mockClear(); captureDocument.mockImplementation(() => Promise.resolve({ @@ -643,29 +643,27 @@ async function freshPlane(opts?: { await plane.add({ tenantId: TENANT, principalId: PRINCIPAL, - content: { title: "Private note", text: "secret" }, - share: { mode: "private", block: ["blocked-p"] }, + content: { title: "Team note", text: "shared" }, + share: { tenant: true }, }); const call = captureDocument.mock.calls[0] as unknown as [ unknown, { document: { - visibility: { mode: string; principalIds?: string[] }; - attributes?: { acl_block?: string }; + accessTags: string[]; }; }, ]; - expect(call[1].document.visibility).toEqual({ - mode: "private", - principalIds: [PRINCIPAL], - }); - expect(call[1].document.attributes?.acl_block).toBe( - JSON.stringify(["blocked-p"]), + expect(call[1].document.accessTags).toContain( + `knowledge.owner:${PRINCIPAL}`, + ); + expect(call[1].document.accessTags).toContain( + `knowledge.tenant:${TENANT}`, ); await plane.close(); }); - it("maps share principals and always includes the owner", async () => { + it("maps share.principals to peer owner tags", async () => { captureDocument.mockClear(); captureDocument.mockImplementation(() => Promise.resolve({ @@ -680,38 +678,51 @@ async function freshPlane(opts?: { tenantId: TENANT, principalId: PRINCIPAL, content: { title: "Shared", text: "body" }, - share: { mode: "principals", principalIds: ["alice", "bob"] }, + share: { principals: ["alice", "bob"] }, }); const call = captureDocument.mock.calls[0] as unknown as [ unknown, { document: { - visibility: { mode: string; principalIds?: string[] }; + accessTags: string[]; }; }, ]; - expect(call[1].document.visibility.mode).toBe("principals"); - const ids = call[1].document.visibility.principalIds ?? []; - expect(ids).toContain(PRINCIPAL); - expect(ids).toContain("alice"); - expect(ids).toContain("bob"); + expect(call[1].document.accessTags).toContain( + `knowledge.owner:${PRINCIPAL}`, + ); + expect(call[1].document.accessTags).toContain("knowledge.owner:alice"); + expect(call[1].document.accessTags).toContain("knowledge.owner:bob"); await plane.close(); }); - it("rejects share together with visibility", async () => { + it("defaults to owner-only access tags", async () => { + captureDocument.mockClear(); + captureDocument.mockImplementation(() => + Promise.resolve({ + status: "captured" as const, + documentId: "kdoc_default", + versionId: "kver_1", + chunks: 1, + }), + ); const plane = await freshPlane(); - try { - await plane.add({ - tenantId: TENANT, - principalId: PRINCIPAL, - content: { title: "T", text: "body" }, - share: { mode: "tenant" }, - visibility: { mode: "private", principalIds: [PRINCIPAL] }, - }); - throw new Error("expected add() to reject"); - } catch (err) { - expectKnowledgeError400(err, "share or visibility"); - } + await plane.add({ + tenantId: TENANT, + principalId: PRINCIPAL, + content: { title: "T", text: "body" }, + }); + const call = captureDocument.mock.calls[0] as unknown as [ + unknown, + { + document: { + accessTags: string[]; + }; + }, + ]; + expect(call[1].document.accessTags).toEqual([ + `knowledge.owner:${PRINCIPAL}`, + ]); await plane.close(); }); }); diff --git a/src/knowledge.ts b/src/knowledge.ts index 8d15b19..c087c1b 100644 --- a/src/knowledge.ts +++ b/src/knowledge.ts @@ -1,18 +1,16 @@ -/** - * Knowledge plane: green surface add / find / ask / recent. - * - * One product path — always store-backed. Default store is the engine's - * pgvector DocumentStore; hosts inject Mem0 / Supermemory / fakes the same way. - */ import { authorize } from "@intx/authz"; -import { blockedDocumentIds } from "./acl.ts"; +import { + canAccessDocument, + resolveAccessTags, + ownerTag, + type ShareSugar, +} from "./acl.ts"; import type { EngineConfig } from "./config.ts"; import { log } from "./log.ts"; import { createDb, type Db, type RawSql } from "./db/client.ts"; import { createFtsVerification, parseFtsLanguage } from "./core/fts-language.ts"; import { createRawSqlClient } from "./core/embed-sql.ts"; -import type { VisibilitySpec } from "./core/schemas/document.ts"; import type { SearchHit } from "./core/schemas/search.ts"; import { validateRerankConfig } from "./core/rerank-client.ts"; import { captureDocument } from "./services/capture.ts"; @@ -26,7 +24,6 @@ import { import { listTimelineEvents, type TimelineEvent, - DEFAULT_TIMELINE_LIMIT, } from "./services/timeline.ts"; import { LIVE_TIMEOUT_MS, @@ -40,14 +37,16 @@ import type { KnowledgeConfig } from "./mount-config.ts"; import type { GrantConfig } from "./routes/deps.ts"; import type { DocumentStore, + DocumentStoreFindParams, MemoryProvider, SourceProvider, } from "./ports/types.ts"; +// (drizzle select was used briefly for grant-tag load; raw sql keeps unit-test +// mocks simple and matches the rest of the engine store.) // Re-export so hosts typing plane results don't reach into services/. export type { HybridSearchResult } from "./services/search.ts"; export type { SearchHit } from "./core/schemas/search.ts"; -export type { VisibilitySpec } from "./core/schemas/document.ts"; export type { DocumentStore, DocumentStoreAddParams, @@ -55,6 +54,13 @@ export type { MemoryProvider, SourceProvider, } from "./ports/types.ts"; +export { + resolveAccessTags, + ownerTag, + tenantTag, + canAccessDocument, + type ShareSugar, +} from "./acl.ts"; export type ChatMessage = { role: "system" | "user" | "assistant"; @@ -98,7 +104,7 @@ export const FIND_LIMIT_MAX = 50; /** Green recent limit bounds (matches timeline service default/cap). */ export const RECENT_LIMIT_MIN = 1; -export const RECENT_LIMIT_MAX = DEFAULT_TIMELINE_LIMIT; +export const RECENT_LIMIT_MAX = 100; export type KnowledgeFindParams = KnowledgeIdentity & { query: string; @@ -162,10 +168,7 @@ export class KnowledgeNotPermittedError extends Error { } } -export type KnowledgeShare = - | { mode: "private" } - | { mode: "tenant" } - | { mode: "principals"; principalIds: string[] }; +export type KnowledgeShare = ShareSugar; export type KnowledgeAddParams = KnowledgeIdentity & { /** Exactly one of `content` or `file` is required. */ @@ -179,15 +182,15 @@ export type KnowledgeAddParams = KnowledgeIdentity & { kind?: string; adapter?: string; externalRef?: string; - /** Direct escape hatch; mutually exclusive with `share`. */ - visibility?: VisibilitySpec; - /** Direct escape hatch; with `share`, use `share.block` instead. */ - blockPrincipalIds?: string[]; /** - * Sugar for visibility + optional block list. - * Mutually exclusive with `visibility`. + * Explicit resource tags in grant-pattern space. Always merged with the + * owner tag for the caller. + */ + accessTags?: string[]; + /** + * Share sugar — only mints tags (tenant / peer owners / explicit tags). */ - share?: KnowledgeShare & { block?: string[] }; + share?: ShareSugar; attributes?: Record; }; @@ -375,8 +378,8 @@ export type KnowledgePlaneOptions = { textExtractor?: TextExtractor; /** * Override durable storage. When set, the plane does not open Postgres or - * call embed/rerank endpoints — use for fakes and replaceable backends - * (Mem0, Supermemory). When omitted, the default engine DocumentStore is used. + * call embed/rerank endpoints — use for fakes and host DocumentStore + * backends. When omitted, the default engine DocumentStore is used. */ documentStore?: DocumentStore; /** @@ -453,51 +456,16 @@ function findItemsToHits(items: readonly FindItem[]): SearchHit[] { })); } -function resolveShareAndVisibility(params: KnowledgeAddParams): { - visibility: VisibilitySpec; - blockPrincipalIds?: string[]; -} { - if (params.share !== undefined && params.visibility !== undefined) { - throw new KnowledgeError( - 400, - "provide share or visibility, not both", - ); - } - - if (params.share !== undefined) { - if (params.blockPrincipalIds !== undefined) { - throw new KnowledgeError( - 400, - "provide share.block or blockPrincipalIds, not both", - ); - } - const share = params.share; - let visibility: VisibilitySpec; - if (share.mode === "private") { - visibility = { - mode: "private", - principalIds: [params.principalId], - }; - } else if (share.mode === "tenant") { - visibility = { mode: "tenant" }; - } else { - const ids = new Set(share.principalIds); - ids.add(params.principalId); - visibility = { mode: "principals", principalIds: [...ids] }; - } - const block = share.block; - return { - visibility, - ...(block && block.length > 0 ? { blockPrincipalIds: block } : {}), - }; - } - - return { - visibility: params.visibility ?? { mode: "tenant" as const }, - ...(params.blockPrincipalIds !== undefined - ? { blockPrincipalIds: params.blockPrincipalIds } - : {}), - }; +/** + * Resolve access tags for add — share sugar + explicit tags only. + */ +function resolveAddAccessTags(params: KnowledgeAddParams): string[] { + return resolveAccessTags({ + principalId: params.principalId, + tenantId: params.tenantId, + ...(params.accessTags !== undefined ? { accessTags: params.accessTags } : {}), + ...(params.share !== undefined ? { share: params.share } : {}), + }); } /** @@ -505,13 +473,14 @@ function resolveShareAndVisibility(params: KnowledgeAddParams): { * * One product path: every plane is store-backed. When `options.documentStore` * is omitted, the default pgvector engine is wrapped as that store. Hosts - * inject Mem0 / Supermemory / fakes the same way — no second plane implementation. + * inject a DocumentStore or fakes the same way — no second plane implementation. * * - `grants` is required for `ask()` (in-process capability check). Standalone * add/find callers may omit it. * - Rerank config is validated at construction when using the default store. * - Pass `options.sources` for live SourceProviders; find/ask merge via * MergeLocalLiveV1 (fail-soft, 800ms timeout, prefer-local dedupe). + * - Document access uses grant tags via the host GrantStore (not mini-ACL). */ export function createKnowledgePlane( config: KnowledgeConfig | undefined, @@ -772,6 +741,10 @@ function createPlaneFromStore( ...(params.entityIds !== undefined ? { entityIds: params.entityIds } : {}), + ...(grants !== undefined ? { grants: grants.grantStore } : {}), + ...(grants?.conditionRegistry !== undefined + ? { conditionRegistry: grants.conditionRegistry } + : {}), }); localItems = local.items.map((it) => ({ documentId: it.documentId, @@ -835,7 +808,7 @@ function createPlaneFromStore( // HTTP surface's `requireGrant("knowledge", ...)` route guard, so the // check has to live here — AUTH.md is explicit that the capability and // data layers are independent and BOTH must allow. Per-document - // visibility (enforced inside the store) is not a substitute for "may + // grant-tag access (enforced inside the store) is not a substitute for "may // this principal search at all". if (!grants) { throw new KnowledgeError( @@ -942,8 +915,7 @@ function createPlaneFromStore( file.title ?? extracted.title ?? file.filename ?? "untitled"; } - const { visibility, blockPrincipalIds } = - resolveShareAndVisibility(params); + const accessTags = resolveAddAccessTags(params); const externalRef = params.externalRef ?? @@ -954,9 +926,8 @@ function createPlaneFromStore( principalId: params.principalId, title, text, - visibility, + accessTags, externalRef, - ...(blockPrincipalIds !== undefined ? { blockPrincipalIds } : {}), ...(params.attributes !== undefined ? { attributes: params.attributes } : {}), @@ -971,12 +942,17 @@ function createPlaneFromStore( tenantId: params.tenantId, principalId: params.principalId, ...(limit !== undefined ? { limit } : {}), + ...(grants !== undefined ? { grants: grants.grantStore } : {}), + ...(grants?.conditionRegistry !== undefined + ? { conditionRegistry: grants.conditionRegistry } + : {}), }); }, remember: memoryApi.remember, recall: memoryApi.recall, + async close() { await store.close(); }, @@ -987,8 +963,8 @@ function createPlaneFromStore( /** * Default DocumentStore: engine pgvector + hybrid search + timeline. - * Owns construction-time rerank validation, FTS verification, and ACL - * block-list post-filter. The plane never opens Postgres itself. + * Owns construction-time rerank validation, FTS verification, and grant-tag + * post-filter for document access. The plane never opens Postgres itself. */ function createEngineDocumentStore(config: KnowledgeConfig): DocumentStore { // Catch a chunk-size / reranker-limit mismatch at construction time, rather @@ -1033,7 +1009,7 @@ function createEngineDocumentStore(config: KnowledgeConfig): DocumentStore { ); /** - * Hybrid retrieval + block-list post-filter. + * Hybrid retrieval + grant-tag post-filter (security boundary). * Returns the full HybridSearchResult so evidence/degrade pass through. */ async function retrieve(params: { @@ -1043,6 +1019,8 @@ function createEngineDocumentStore(config: KnowledgeConfig): DocumentStore { k?: number; kinds?: string[]; entityIds?: string[]; + grants?: DocumentStoreFindParams["grants"]; + conditionRegistry?: DocumentStoreFindParams["conditionRegistry"]; }): Promise { try { await ensureVerified(); @@ -1057,43 +1035,66 @@ function createEngineDocumentStore(config: KnowledgeConfig): DocumentStore { : {}), }); - // Block-list post-filter: docs may store acl_block as a list of - // principal ids. Engine visibility does not model block lists yet. - // Shared with recent via readBlockList / blockedDocumentIds. if (result.hits.length === 0) return result; const docIds = Array.from( new Set(result.hits.map((h) => h.document_id)), ); + + // Load access_tags + active-version creator for grant-tag check. + // Raw SQL so unit tests can mock `sql` without a real drizzle client. const rows = await sql< - { id: string; attributes: Record | null }[] + { + id: string; + access_tags: string[] | null; + created_by: string | null; + }[] >` - SELECT id, attributes - FROM "knowledge"."document" - WHERE id = ANY(${docIds}::text[]) + SELECT d.id, + d.access_tags, + v.created_by_principal_id AS created_by + FROM "knowledge"."document" d + LEFT JOIN "knowledge"."version" v + ON v.document_id = d.id + AND v.status = 'active' + AND v.generation = 'live' + WHERE d.id = ANY(${docIds}::text[]) `; - const { blocked, unreadable } = blockedDocumentIds( - docIds, - rows, - params.principalId, + + const byId = new Map( + rows.map((r) => [ + r.id, + { + accessTags: (r.access_tags ?? []) as string[], + createdByPrincipalId: r.created_by, + }, + ]), ); - if (unreadable.length > 0) { - // Cap the sample so a large withhold batch cannot flood logs; count - // is always present so the full size is still auditable. - const sampleLimit = 20; - const documentIds = unreadable.slice(0, sampleLimit); - const more = - unreadable.length > sampleLimit - ? ` (+${unreadable.length - sampleLimit} more)` - : ""; - log.warn( - `find: ${unreadable.length} document(s) had an unreadable acl_block or missing row; withholding: ${documentIds.join(", ")}${more}`, - { count: unreadable.length, documentIds }, - ); + + const allowed = new Set(); + for (const id of docIds) { + const meta = byId.get(id); + if (!meta) continue; + // No grants → creator-only (safe default for standalone / unit tests). + if (!params.grants) { + if (meta.createdByPrincipalId === params.principalId) { + allowed.add(id); + } + continue; + } + const ok = await canAccessDocument({ + grants: params.grants, + tenantId: params.tenantId, + principalId: params.principalId, + createdByPrincipalId: meta.createdByPrincipalId, + accessTags: meta.accessTags, + ...(params.conditionRegistry !== undefined + ? { conditionRegistry: params.conditionRegistry } + : {}), + }); + if (ok) allowed.add(id); } - if (blocked.size === 0) return result; - const hits = result.hits.filter((h) => !blocked.has(h.document_id)); - // result.evidence is already "none" only when there were no hits, so - // a post-filter that empties the list is the only way to reach "none". + + const hits = result.hits.filter((h) => allowed.has(h.document_id)); return { ...result, hits, @@ -1115,12 +1116,7 @@ function createEngineDocumentStore(config: KnowledgeConfig): DocumentStore { const externalRef = params.externalRef ?? `knowledge:${params.tenantId}:${crypto.randomUUID()}`; - const attributes: Record = { - ...(params.attributes ?? {}), - }; - if (params.blockPrincipalIds && params.blockPrincipalIds.length > 0) { - attributes["acl_block"] = JSON.stringify(params.blockPrincipalIds); - } + const accessTags = params.accessTags ?? [ownerTag(params.principalId)]; const captureResult = await captureDocument(deps, { tenantId: params.tenantId, @@ -1130,12 +1126,14 @@ function createEngineDocumentStore(config: KnowledgeConfig): DocumentStore { kind: params.kind ?? "note", title: params.title, externalRef, - visibility: params.visibility, + accessTags, entityHints: [], chunks: [{ ordinal: 0, text: params.text }], actor: { kind: "human", principalId: params.principalId }, contentHash: "", // recomputed canonically in adapt-and-plan - ...(Object.keys(attributes).length > 0 ? { attributes } : {}), + ...(params.attributes !== undefined + ? { attributes: params.attributes } + : {}), }, }); return { documentId: captureResult.documentId }; @@ -1151,6 +1149,10 @@ function createEngineDocumentStore(config: KnowledgeConfig): DocumentStore { ...(params.entityIds !== undefined ? { entityIds: params.entityIds } : {}), + ...(params.grants !== undefined ? { grants: params.grants } : {}), + ...(params.conditionRegistry !== undefined + ? { conditionRegistry: params.conditionRegistry } + : {}), }); const items = hitsToFindItems(result.hits); if (params.includeEvidence) { @@ -1172,6 +1174,10 @@ function createEngineDocumentStore(config: KnowledgeConfig): DocumentStore { tenantId: params.tenantId, principalId: params.principalId, ...(params.limit !== undefined ? { limit: params.limit } : {}), + ...(params.grants !== undefined ? { grants: params.grants } : {}), + ...(params.conditionRegistry !== undefined + ? { conditionRegistry: params.conditionRegistry } + : {}), }); }, diff --git a/src/ports/fakes.test.ts b/src/ports/fakes.test.ts index 0850572..310c35c 100644 --- a/src/ports/fakes.test.ts +++ b/src/ports/fakes.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "bun:test"; +import { createInMemoryGrantStore } from "@intx/authz"; +import { ownerTag, tenantTag } from "../acl.ts"; import { createFakeDocumentStore, createFakeSourceProvider, @@ -17,7 +19,7 @@ describe("createFakeDocumentStore", () => { principalId: PRINCIPAL, title: "standup notes", text: "shipped the ports foundation", - visibility: { mode: "tenant" }, + accessTags: [ownerTag(PRINCIPAL), tenantTag(TENANT)], }); expect(documentId).toMatch(/^fake_doc_/); @@ -39,14 +41,14 @@ describe("createFakeDocumentStore", () => { await store.close(); }); - it("respects private visibility", async () => { + it("creator-only without grants; other principal cannot see", async () => { const store = createFakeDocumentStore(); await store.add({ tenantId: TENANT, principalId: PRINCIPAL, title: "secret", text: "classified payload", - visibility: { mode: "private", principalIds: [PRINCIPAL] }, + accessTags: [ownerTag(PRINCIPAL)], }); const asOwner = await store.find({ tenantId: TENANT, @@ -63,22 +65,35 @@ describe("createFakeDocumentStore", () => { await store.close(); }); - it("honours block list", async () => { + it("peer with grant on owner tag can see", async () => { const store = createFakeDocumentStore(); await store.add({ tenantId: TENANT, principalId: PRINCIPAL, - title: "blocked from other", + title: "shared note", text: "visible body", - visibility: { mode: "tenant" }, - blockPrincipalIds: [OTHER], + accessTags: [ownerTag(PRINCIPAL), ownerTag(OTHER)], }); + const grants = createInMemoryGrantStore([ + { + id: "g1", + principalId: OTHER, + resource: ownerTag(OTHER), + action: "find", + effect: "allow", + origin: "role", + conditions: null, + expiresAt: null, + roleId: null, + }, + ]); const asOther = await store.find({ tenantId: TENANT, principalId: OTHER, query: "visible", + grants, }); - expect(asOther.items).toHaveLength(0); + expect(asOther.items).toHaveLength(1); await store.close(); }); }); diff --git a/src/ports/fakes.ts b/src/ports/fakes.ts index f5f2e70..207d356 100644 --- a/src/ports/fakes.ts +++ b/src/ports/fakes.ts @@ -1,7 +1,12 @@ /** * In-package fakes for DocumentStore and SourceProvider. * Enough for hosts/tests to mount without Postgres or embed endpoints. + * + * Document access: creator always sees own docs; otherwise any accessTag that + * authorize(grants, …, tag, "find") allows when grants are provided. Without + * grants, only creator access (safe default for unit tests). */ +import { canAccessDocument } from "../acl.ts"; import type { DocumentStore, DocumentStoreAddParams, @@ -20,20 +25,33 @@ type StoredDoc = { principalId: string; title: string; text: string; - visibility: DocumentStoreAddParams["visibility"]; - blockPrincipalIds: string[]; + accessTags: string[]; externalRef?: string; createdAt: string; }; -function visibleTo(doc: StoredDoc, principalId: string): boolean { - if (doc.blockPrincipalIds.includes(principalId)) return false; - const v = doc.visibility; - if (v.mode === "tenant") return true; - if (v.mode === "private" || v.mode === "principals") { - return (v.principalIds ?? []).includes(principalId); +async function visibleTo( + doc: StoredDoc, + params: { + principalId: string; + tenantId: string; + grants?: DocumentStoreFindParams["grants"]; + conditionRegistry?: DocumentStoreFindParams["conditionRegistry"]; + }, +): Promise { + if (!params.grants) { + return doc.principalId === params.principalId; } - return false; + return canAccessDocument({ + grants: params.grants, + tenantId: params.tenantId, + principalId: params.principalId, + createdByPrincipalId: doc.principalId, + accessTags: doc.accessTags, + ...(params.conditionRegistry !== undefined + ? { conditionRegistry: params.conditionRegistry } + : {}), + }); } function scoreMatch(query: string, title: string, text: string): number { @@ -46,14 +64,14 @@ function scoreMatch(query: string, title: string, text: string): number { } /** - * In-memory DocumentStore. ACL-aware substring match; no embeddings. + * In-memory DocumentStore. Grant-tag ACL when grants passed; creator-only otherwise. */ export function createFakeDocumentStore(): DocumentStore { const docs: StoredDoc[] = []; let seq = 0; return { - async add(params) { + async add(params: DocumentStoreAddParams) { const documentId = `fake_doc_${++seq}`; const row: StoredDoc = { documentId, @@ -61,8 +79,7 @@ export function createFakeDocumentStore(): DocumentStore { principalId: params.principalId, title: params.title, text: params.text, - visibility: params.visibility, - blockPrincipalIds: params.blockPrincipalIds ?? [], + accessTags: [...params.accessTags], createdAt: new Date().toISOString(), }; if (params.externalRef !== undefined) { @@ -76,17 +93,15 @@ export function createFakeDocumentStore(): DocumentStore { params: DocumentStoreFindParams, ): Promise { const limit = params.limit ?? 8; - const items = docs - .filter( - (d) => - d.tenantId === params.tenantId && - visibleTo(d, params.principalId), - ) - .map((d) => { - const score = scoreMatch(params.query, d.title, d.text); - return { d, score }; - }) - .filter((x) => x.score > 0) + const scored: { d: StoredDoc; score: number }[] = []; + for (const d of docs) { + if (d.tenantId !== params.tenantId) continue; + const ok = await visibleTo(d, params); + if (!ok) continue; + const score = scoreMatch(params.query, d.title, d.text); + if (score > 0) scored.push({ d, score }); + } + const items = scored .sort((a, b) => b.score - a.score) .slice(0, limit) .map(({ d, score }) => ({ @@ -119,21 +134,23 @@ export function createFakeDocumentStore(): DocumentStore { params: DocumentStoreRecentParams, ): Promise { const limit = params.limit ?? 50; - return docs - .filter( - (d) => - d.tenantId === params.tenantId && - visibleTo(d, params.principalId), - ) - .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)) - .slice(0, limit) - .map((d) => ({ + const out: DocumentStoreRecentEvent[] = []; + const sorted = [...docs] + .filter((d) => d.tenantId === params.tenantId) + .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)); + for (const d of sorted) { + if (out.length >= limit) break; + const ok = await visibleTo(d, params); + if (!ok) continue; + out.push({ at: d.createdAt, title: d.title, source: "fake", tenantId: d.tenantId, principalId: d.principalId, - })); + }); + } + return out; }, async close() { @@ -152,52 +169,42 @@ export function createFakeSourceProvider( return { id, async searchLive(params) { - const q = params.query.toLowerCase(); + const q = params.query.toLowerCase().trim(); const limit = params.limit ?? 8; return catalog - .filter((item) => { - if (item.adapter !== id) return false; - // Fail-closed: seed rows may carry tenantId for tenancy tests. - const row = item as LiveSearchItem & { tenantId?: string }; - if (row.tenantId !== undefined && row.tenantId !== params.tenantId) { - return false; - } - return ( + .filter( + (item) => + !q || item.title.toLowerCase().includes(q) || - item.snippet.toLowerCase().includes(q) - ); - }) + item.snippet.toLowerCase().includes(q), + ) .slice(0, limit); }, }; } -/** In-memory MemoryProvider for tests and host-with-fakes-only mounts. */ +/** + * In-memory MemoryProvider for tests. + */ export function createFakeMemoryProvider(): MemoryProvider { - const mem: Array<{ - tenantId: string; - principalId: string; - text: string; - }> = []; + const byKey = new Map(); + const key = (tenantId: string, principalId: string) => + `${tenantId}::${principalId}`; return { async remember(params) { - mem.push({ - tenantId: params.tenantId, - principalId: params.principalId, - text: params.text, - }); + const k = key(params.tenantId, params.principalId); + const list = byKey.get(k) ?? []; + list.push(params.text); + byKey.set(k, list); }, async recall(params) { + const list = byKey.get(key(params.tenantId, params.principalId)) ?? []; const q = params.query.toLowerCase(); - return mem - .filter( - (m) => - m.tenantId === params.tenantId && - m.principalId === params.principalId && - m.text.toLowerCase().includes(q), - ) - .slice(0, params.limit ?? 5) - .map((m) => ({ text: m.text, score: 1 })); + const limit = params.limit ?? 5; + return list + .filter((t) => !q || t.toLowerCase().includes(q)) + .slice(0, limit) + .map((text) => ({ text, score: 1 })); }, }; } diff --git a/src/ports/merge-plane.test.ts b/src/ports/merge-plane.test.ts index c6a1f0e..e5c62c2 100644 --- a/src/ports/merge-plane.test.ts +++ b/src/ports/merge-plane.test.ts @@ -181,7 +181,7 @@ describe("plane merge (MergeLocalLiveV1)", () => { principalId: PRINCIPAL, title: "local CL-7 body", text: "collision payload local", - visibility: { mode: "tenant" }, + accessTags: [`knowledge.owner:${PRINCIPAL}`], externalRef: "CL-7", }); // Fake store citation uses adapter "fake" not linear — force collision by diff --git a/src/ports/types.ts b/src/ports/types.ts index 37f9087..dc1888f 100644 --- a/src/ports/types.ts +++ b/src/ports/types.ts @@ -2,12 +2,15 @@ * Port contracts for pluggable storage, live sources, and optional personal memory. * * DocumentStore is the durable backend for add/find/recent (default: local - * pgvector). Hosts replace it with Mem0, Supermemory, or fakes — no dual store. - * SourceProvider is tools-shaped live connectors (e.g. Linear), not a store. - * MemoryProvider is an optional ask side-channel (includeMemory); not how you - * swap backends. + * pgvector). Hosts replace it with any DocumentStore implementation or fakes — + * no dual store. SourceProvider is tools-shaped live connectors (e.g. Linear), + * not a store. MemoryProvider is an optional ask side-channel (includeMemory); + * not how you swap backends. + * + * Document access uses Interchange grant tags (accessTags), not a mini-ACL. + * See docs/AUTHZ-DOCUMENT-ACCESS.md. */ -import type { VisibilitySpec } from "../core/schemas/document.ts"; +import type { GrantStore, ConditionRegistry } from "@intx/authz"; import type { SearchEvidence, SearchHit, @@ -21,8 +24,8 @@ export type DocumentStoreAddParams = { principalId: string; title: string; text: string; - visibility: VisibilitySpec; - blockPrincipalIds?: string[]; + /** Resource tags in grant-pattern space (security boundary). */ + accessTags: string[]; attributes?: Record; externalRef?: string; /** Capture adapter id (default engine store uses `"http"`). */ @@ -41,6 +44,12 @@ export type DocumentStoreFindParams = { kinds?: string[]; /** Narrow local retrieval by linked entity ids (unset/`[]` = no filter). */ entityIds?: string[]; + /** + * Host grant store for grant-tag document access (default engine + fakes). + * Vendor stores may ignore (principal-bucket only). + */ + grants?: GrantStore; + conditionRegistry?: ConditionRegistry; }; export type DocumentStoreFindItem = { @@ -66,6 +75,8 @@ export type DocumentStoreRecentParams = { tenantId: string; principalId: string; limit?: number; + grants?: GrantStore; + conditionRegistry?: ConditionRegistry; }; export type DocumentStoreRecentEvent = { @@ -78,7 +89,7 @@ export type DocumentStoreRecentEvent = { /** * Durable document plane. Default implementation is the engine's pgvector - * store. Hosts inject Mem0 / Supermemory / fakes via `options.documentStore` + * store. Hosts inject a DocumentStore (or fakes) via `options.documentStore` * to replace local Postgres entirely — this is the only product path for * swapping backends. */ @@ -124,7 +135,7 @@ export type SourceProvider = { /** * Optional personal-memory side channel for ask(includeMemory). Not a - * DocumentStore replacement — Mem0/Supermemory product adapters implement + * DocumentStore replacement — product backends for durable knowledge implement * DocumentStore, not this port. */ export type MemoryProvider = { @@ -142,5 +153,4 @@ export type MemoryProvider = { }): Promise>; }; -// Keep SearchHit import used if needed by consumers re-exporting citation shapes. export type { SearchHit }; diff --git a/src/routes/add.ts b/src/routes/add.ts index a64525d..e59cd6b 100644 --- a/src/routes/add.ts +++ b/src/routes/add.ts @@ -4,15 +4,24 @@ import { describeRoute, resolver, validator } from "hono-openapi"; import { type } from "arktype"; import { formatCaughtError, log } from "../log.ts"; -import { parseAcl } from "../acl.ts"; +import { resolveAccessTags, type ShareSugar } from "../acl.ts"; import { KnowledgeError } from "../knowledge.ts"; import type { RouteDeps } from "./deps.ts"; import { caller, grantGuard, requirePrincipal } from "./deps.ts"; +const ShareBody = type({ + "tenant?": "boolean", + "principals?": "string[]", + "tags?": "string[]", +}); + const AddRequest = type({ title: "string >= 1", text: "string >= 1", - "acl?": "unknown", + /** Explicit resource tags (grant-pattern space). */ + "access_tags?": "string[]", + /** Share sugar — mints tags only. */ + "share?": ShareBody, }); const AddResponse = type({ @@ -32,7 +41,7 @@ export function mountAddRoute(app: Hono, deps: RouteDeps): void { "application/json": { schema: resolver(AddResponse) }, }, }, - 400: { description: "Invalid request or ACL" }, + 400: { description: "Invalid request or access tags" }, 401: { description: "No principal on the request context" }, 403: { description: "Missing the knowledge:add grant" }, 502: { description: "add failed" }, @@ -42,19 +51,30 @@ export function mountAddRoute(app: Hono, deps: RouteDeps): void { grantGuard(deps, "add"), validator("json", AddRequest), async (c) => { - const { title, text, acl } = c.req.valid("json"); + const body = c.req.valid("json"); + const { title, text } = body; const { scopeId, subjectId } = caller(c); - const parsed = parseAcl(acl, subjectId); - if (!parsed.ok) return c.json({ error: parsed.error }, 400); + const accessTags = body.access_tags; + const share = body.share as ShareSugar | undefined; + + // Validate tag resolution early (empty strings stripped, owner always present). + if (accessTags || share) { + resolveAccessTags({ + principalId: subjectId, + tenantId: scopeId, + ...(accessTags !== undefined ? { accessTags } : {}), + ...(share !== undefined ? { share } : {}), + }); + } try { const { documentId } = await deps.knowledge.add({ content: { title, text }, tenantId: scopeId, principalId: subjectId, - visibility: parsed.visibility, - blockPrincipalIds: parsed.block, + ...(accessTags !== undefined ? { accessTags } : {}), + ...(share !== undefined ? { share } : {}), }); return c.json({ documentId }); } catch (err) { diff --git a/src/services/capture.ts b/src/services/capture.ts index 3e5e777..ca5fde6 100644 --- a/src/services/capture.ts +++ b/src/services/capture.ts @@ -345,6 +345,7 @@ async function deriveVersionInTransaction( if (!existingDoc) { const documentId = newId("kdoc"); + // accessTags is the security boundary for document access. await tx.insert(knowledgeDocument).values({ id: documentId, tenantId: input.tenantId, @@ -352,9 +353,7 @@ async function deriveVersionInTransaction( title: doc.title, adapter: input.adapter, externalRef: doc.externalRef, - visibilityMode: doc.visibility.mode, - visibilityPrincipalIds: doc.visibility.principalIds ?? null, - visibilitySourceAcl: doc.visibility.sourceAcl ?? null, + accessTags: doc.accessTags, attributes: doc.attributes ?? {}, createdAt: now, lastSeenAt: now, @@ -423,9 +422,7 @@ async function deriveVersionInTransaction( .update(knowledgeDocument) .set({ title: doc.title, - visibilityMode: doc.visibility.mode, - visibilityPrincipalIds: doc.visibility.principalIds ?? null, - visibilitySourceAcl: doc.visibility.sourceAcl ?? null, + accessTags: doc.accessTags, attributes: doc.attributes ?? {}, lastSeenAt: now, }) diff --git a/src/services/search.test.ts b/src/services/search.test.ts index 26a46cf..ebeb318 100644 --- a/src/services/search.test.ts +++ b/src/services/search.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from "bun:test"; -import { PgDialect } from "drizzle-orm/pg-core"; import { authorityWeightedScore, dedupeCandidatesPerDocument, @@ -7,15 +6,9 @@ import { fetchDenseCandidates, hnswEfSearch, snippet, - visibilityPredicateSql, - visibilityPredicateRawSql, - VISIBILITY_PREDICATE_RAW_SQL, - VISIBILITY_PREDICATE_RAW_SQL_NULL_PRINCIPAL, type CandidateRow, } from "./search.ts"; -const dialect = new PgDialect(); - function candidate(overrides: Partial = {}): CandidateRow { return { chunkId: "chunk_1", @@ -37,64 +30,6 @@ function candidate(overrides: Partial = {}): CandidateRow { }; } -describe("visibilityPredicateSql", () => { - it("scopes 'principals'/'private' docs to a JSON array containing the given principal id", () => { - const { sql, params } = dialect.sqlToQuery(visibilityPredicateSql("principal_a")); - expect(sql).toContain("visibility_mode"); - expect(sql).toContain("'tenant'"); - expect(sql).toContain("'principals', 'private'"); - expect(params).toContain(JSON.stringify(["principal_a"])); - }); - - it("never matches a 'principals'/'private' doc when principalId is null — returns a tenant-only predicate with NO principal_ids check at all", () => { - const { sql, params } = dialect.sqlToQuery(visibilityPredicateSql(null)); - // Regression guard: a null principal must NOT be modeled as "matches an - // empty principal_ids array" — jsonb `@>` containment treats the empty - // array as a subset of EVERY array (`'["x"]'::jsonb @> '[]'::jsonb` is - // TRUE), so that shape would vacuously match any 'principals'/'private' - // doc regardless of its actual principal_ids. The null-principal - // predicate is instead the plain, unconditional 'tenant' check — no - // principal_ids column reference, no jsonb params, at all. - expect(sql).toContain("visibility_mode"); - expect(sql).toContain("'tenant'"); - expect(sql).not.toContain("principal_ids"); - expect(sql).not.toContain("'principals', 'private'"); - expect(params).toEqual([]); - }); - - it("produces the identical predicate shape as the raw-SQL string used by the dense channel, for BOTH the with-principal and null-principal cases", () => { - function normalize(rawSql: string): string { - return rawSql - .replace(/"knowledge"\."document"\./g, "kd.") - .replace(/"knowledge_document"\./g, "kd.") - .replace(/"(\w+)"/g, "$1") - .replace(/\$\d+/g, "$PARAM") - .replace(/\s+/g, " ") - .trim(); - } - - const withPrincipal = dialect.sqlToQuery(visibilityPredicateSql("principal_a")); - const normalizedRawWithPrincipal = VISIBILITY_PREDICATE_RAW_SQL.replace( - "$VISIBILITY_PRINCIPAL_JSON", - "$PARAM", - ) - .replace(/\s+/g, " ") - .trim(); - expect(normalize(withPrincipal.sql)).toBe(normalizedRawWithPrincipal); - expect(visibilityPredicateRawSql(true)).toBe(VISIBILITY_PREDICATE_RAW_SQL); - - const nullPrincipal = dialect.sqlToQuery(visibilityPredicateSql(null)); - const normalizedRawNullPrincipal = VISIBILITY_PREDICATE_RAW_SQL_NULL_PRINCIPAL.replace( - /\s+/g, - " ", - ).trim(); - expect(normalize(nullPrincipal.sql)).toBe(normalizedRawNullPrincipal); - expect(visibilityPredicateRawSql(false)).toBe( - VISIBILITY_PREDICATE_RAW_SQL_NULL_PRINCIPAL, - ); - }); -}); - describe("authorityWeightedScore", () => { it("boosts a relevance score by up to 50% at authority === 1", () => { expect(authorityWeightedScore(1, 1)).toBeCloseTo(1.5, 10); diff --git a/src/services/search.ts b/src/services/search.ts index d4a93c5..1df27a9 100644 --- a/src/services/search.ts +++ b/src/services/search.ts @@ -44,8 +44,9 @@ import type { } from "../core/schemas/search.ts"; // The single doorway every retrieval read passes through. Every query below -// filters `tenant_id` first, unconditionally, before any visibility logic -// runs — tenant isolation is absolute. +// filters `tenant_id` first, unconditionally. Per-document access is **not** +// decided in SQL — the DocumentStore post-filters with Interchange grant tags +// (`canAccessDocument`). See docs/AUTHZ-DOCUMENT-ACCESS.md. export const MAX_K = 100; export const DEFAULT_HYBRID_TOP_K = 8; @@ -130,61 +131,8 @@ export function snippet(text: string, maxLen = 240): string { return trimmed.length > maxLen ? `${trimmed.slice(0, maxLen)}…` : trimmed; } -// The single visibility predicate every retrieval channel (lexical, and the -// dense channel) must apply. `principalId === null` means the caller has no -// principal identity to check — only tenant-wide-visible documents match; a -// document scoped to `principals`/`private` never matches a null principal. -// -// IMPORTANT: a null principal must NOT be modeled as "matches an empty -// principal_ids array" — jsonb `@>` containment treats the empty array as a -// subset of EVERY array, so `principal_ids @> '[]'::jsonb` is TRUE -// regardless of what's actually in principal_ids, which would let a -// null-principal caller see every `principals`/`private` document (a real -// ACL bypass this predicate previously had). A null principal instead gets -// its own, structurally simpler predicate: tenant-wide-visible documents -// ONLY, with no principal_ids check at all. -export function visibilityPredicateSql(principalId: string | null) { - if (principalId === null) { - return sql`(${knowledgeDocument.visibilityMode} = 'tenant')`; - } - return sql`( - ${knowledgeDocument.visibilityMode} = 'tenant' - OR ( - ${knowledgeDocument.visibilityMode} IN ('principals', 'private') - AND ${knowledgeDocument.visibilityPrincipalIds} @> ${JSON.stringify([principalId])}::jsonb - ) - )`; -} - -// The raw (per-chunk, non-deduped) SQL fragment text mirroring -// `visibilityPredicateSql` above, for the dense channel which queries a -// dynamically-named `knowledge_embedding_` table through the raw -// postgres-js pool (no drizzle schema exists for that table, so the drizzle -// `sql` fragment above cannot be reused verbatim there). Any change to the -// visibility rule must be applied to BOTH this string and -// `visibilityPredicateSql` — there is no third implementation anywhere else. -export const VISIBILITY_PREDICATE_RAW_SQL = `( - kd.visibility_mode = 'tenant' - OR ( - kd.visibility_mode IN ('principals', 'private') - AND kd.visibility_principal_ids @> $VISIBILITY_PRINCIPAL_JSON::jsonb - ) -)`; - -// The null-principal counterpart to `VISIBILITY_PREDICATE_RAW_SQL`, mirroring -// `visibilityPredicateSql(null)`'s tenant-only fragment — no principal_ids -// check, no `$VISIBILITY_PRINCIPAL_JSON` placeholder to substitute. -export const VISIBILITY_PREDICATE_RAW_SQL_NULL_PRINCIPAL = `(kd.visibility_mode = 'tenant')`; - -// The single call site (fetchDenseCandidates) selects between the two raw -// fragments above by whether a principal is present — never re-derive this -// choice, and never let the two fragments' non-null shape drift from -// `visibilityPredicateSql`'s non-null branch. -export function visibilityPredicateRawSql(hasPrincipal: boolean): string { - return hasPrincipal - ? VISIBILITY_PREDICATE_RAW_SQL - : VISIBILITY_PREDICATE_RAW_SQL_NULL_PRINCIPAL; -} +// SQL retrieval is tenant-scoped only. Document access (grant tags + creator) +// is enforced by the DocumentStore after hybridSearch returns candidates. const ADAPTER_OPEN_TYPES: Record = { artifact: "artifact", @@ -377,15 +325,15 @@ interface LexicalCandidateParams extends ChannelFilterFields { } // The single FTS-candidate query for the lexical channel. Returns raw, -// overfetched, non-deduped per-chunk rows already filtered by tenant + ACL + -// status — the caller decides how to combine/dedupe/truncate them. +// overfetched, non-deduped per-chunk rows already filtered by tenant + +// status + generation — the caller decides how to combine/dedupe/truncate +// them. Document access is post-filtered via grant tags. export async function fetchLexicalCandidates( params: LexicalCandidateParams, ): Promise { const { db, tenantId, - principalId, query, ftsLanguage, overfetchLimit, @@ -398,7 +346,6 @@ export async function fetchLexicalCandidates( eq(knowledgeChunk.tenantId, tenantId), eq(knowledgeVersion.status, "active"), eq(knowledgeVersion.generation, generation), - visibilityPredicateSql(principalId), ]; if (kinds && kinds.length > 0) { @@ -502,9 +449,9 @@ export function hnswEfSearch(overfetchLimit: number): number { // Dense channel: embeds the query, then runs an ANN cosine-distance query // against the tenant's ACTIVE embedding table only (never a superseded or -// inactive model's table), joined back to knowledge_chunk/version/document -// with the EXACT SAME visibility predicate as the lexical channel -// (VISIBILITY_PREDICATE_RAW_SQL) — there is no second ACL implementation. +// inactive model's table), joined back to knowledge_chunk/version/document. +// Tenant + status + generation only — document access is post-filtered via +// grant tags (same as the lexical channel). // Returns `null` (not an error) when there is no active embed model // configured for the tenant yet, or when the query is empty — both are // legitimate "dense not applicable" states, distinct from a runtime @@ -517,7 +464,6 @@ export async function fetchDenseCandidates( embedClientConfig, fetchImpl, tenantId, - principalId, query, overfetchLimit, kinds, @@ -542,23 +488,9 @@ export async function fetchDenseCandidates( const efSearch = hnswEfSearch(overfetchLimit); - // Placeholder numbering depends on whether a principal is present: the - // null-principal fragment (VISIBILITY_PREDICATE_RAW_SQL_NULL_PRINCIPAL) - // never references a principal placeholder at all, and Postgres cannot - // infer a type for a varparam that no fragment of the query references - // (error 42P18). So the principal value is only bound when a principal - // exists, and each placeholder is derived from its position in the - // params array rather than hand-numbered. - const hasPrincipal = principalId !== null; + // Tenant isolation is absolute (bound as $1). Document access is **not** + // applied in SQL — the DocumentStore post-filters with Interchange grant tags. const params: unknown[] = [tenantId]; - let visibilitySql = visibilityPredicateRawSql(hasPrincipal); - if (hasPrincipal) { - params.push(JSON.stringify([principalId])); - visibilitySql = visibilitySql.replace( - "$VISIBILITY_PRINCIPAL_JSON", - `$${params.length}`, - ); - } params.push(JSON.stringify(vector)); const vectorParam = `$${params.length}`; params.push(overfetchLimit); @@ -598,7 +530,6 @@ export async function fetchDenseCandidates( JOIN "knowledge"."document" kd ON kd.id = c.document_id WHERE e.tenant_id = $1 AND c.tenant_id = $1 AND kv.status = 'active' AND kv.generation = ${generationParam} - AND ${visibilitySql} ${kindClause} ${entityClause} ORDER BY ${cosineDistanceExpr("e.embedding", vectorParam, activeTable.dims)} ASC diff --git a/src/services/timeline.test.ts b/src/services/timeline.test.ts index 820f1e1..d7b22ec 100644 --- a/src/services/timeline.test.ts +++ b/src/services/timeline.test.ts @@ -1,297 +1,98 @@ import { describe, expect, it } from "bun:test"; +import { createInMemoryGrantStore } from "@intx/authz"; import { PgDialect } from "drizzle-orm/pg-core"; -import { desc } from "drizzle-orm"; -import { LIVE_GENERATION } from "../core/generation.ts"; -import type { Db } from "../db/client.ts"; -import { knowledgeDocument } from "../db/schema.ts"; import { - DEFAULT_TIMELINE_LIMIT, - filterTimelineRowsForPrincipal, - listTimelineEvents, - timelineActiveVersionJoin, - timelineRowBlock, + filterTimelineRows, timelineWhere, type TimelineRow, } from "./timeline.ts"; const dialect = new PgDialect(); -function row( - overrides: Partial & { id: string; title: string }, -): TimelineRow { +function row(overrides: Partial = {}): TimelineRow { return { - tenantId: "t1", - adapter: "mcp", - lastSeenAt: new Date("2026-01-01T00:00:00.000Z"), - attributes: {}, - principalId: "alice", + documentId: "doc_1", + title: "Title", + adapter: "http", + externalRef: "note:1", + occurredAt: new Date("2026-01-01T00:00:00Z"), + createdByPrincipalId: "owner", + accessTags: ["knowledge.owner:owner"], ...overrides, }; } -/** Minimal drizzle-shaped chain that returns fixed rows from `.limit()`. */ -function mockDb(rows: TimelineRow[]): { - db: Db; - get limitSeen(): number | null; -} { - let limitSeen: number | null = null; - const chain = { - from: () => chain, - innerJoin: () => chain, - where: () => chain, - orderBy: () => chain, - limit: (n: number) => { - limitSeen = n; - return Promise.resolve(rows); - }, - }; - const db = { - select: () => chain, - } as unknown as Db; - return { - db, - get limitSeen() { - return limitSeen; - }, - }; -} - -describe("timelineRowBlock (readBlockList gate shared with search)", () => { - it("allows absent and empty block lists", () => { - expect(timelineRowBlock(null, "p1")).toBe("allow"); - expect(timelineRowBlock({}, "p1")).toBe("allow"); - expect(timelineRowBlock({ acl_block: "[]" }, "p1")).toBe("allow"); - expect(timelineRowBlock({ acl_block: [] }, "p1")).toBe("allow"); - }); - - it("blocks membership for both JSON-string and native array encodings", () => { - expect( - timelineRowBlock({ acl_block: JSON.stringify(["p1"]) }, "p1"), - ).toBe("blocked"); - expect(timelineRowBlock({ acl_block: ["p1"] }, "p1")).toBe("blocked"); - expect(timelineRowBlock({ acl_block: ["other"] }, "p1")).toBe("allow"); - }); - - it("fail-closes on unreadable shapes (non-string, non-array, corrupt JSON)", () => { - expect(timelineRowBlock({ acl_block: 42 }, "p1")).toBe("unreadable"); - expect(timelineRowBlock({ acl_block: { subjects: ["p1"] } }, "p1")).toBe( - "unreadable", +describe("filterTimelineRows (grant-tag access)", () => { + it("allows the creator without grants on tags", async () => { + const { events, withheld } = await filterTimelineRows( + [row({ createdByPrincipalId: "p1", accessTags: [] })], + { principalId: "p1", tenantId: "t1", grants: createInMemoryGrantStore([]) }, ); - expect(timelineRowBlock({ acl_block: "{not-json" }, "p1")).toBe( - "unreadable", - ); - expect(timelineRowBlock({ acl_block: ["p1", 2] }, "p1")).toBe( - "unreadable", - ); - }); -}); - -describe("filterTimelineRowsForPrincipal", () => { - it("keeps tenant-visible events and drops blocked titles for the caller", () => { - const rows: TimelineRow[] = [ - row({ id: "1", title: "team standup notes" }), - row({ - id: "2", - title: "Q3 layoffs — draft list", - attributes: { acl_block: JSON.stringify(["p1"]) }, - }), - row({ - id: "3", - title: "ok for everyone", - attributes: { acl_block: JSON.stringify(["other"]) }, - }), - ]; - const { events, unreadableIds } = filterTimelineRowsForPrincipal( - rows, - "p1", - 100, - ); - expect(unreadableIds).toEqual([]); - expect(events.map((e) => e.title)).toEqual([ - "team standup notes", - "ok for everyone", - ]); - expect(events.map((e) => e.title)).not.toContain("Q3 layoffs — draft list"); + expect(withheld).toBe(0); + expect(events).toHaveLength(1); + expect(events[0]?.title).toBe("Title"); }); - it("fail-closes on unparseable acl_block and does not surface the title", () => { - const rows: TimelineRow[] = [ - row({ - id: "bad", - title: "should not leak", - attributes: { acl_block: "{not-json" }, - }), - row({ id: "ok", title: "visible" }), - ]; - const { events, unreadableIds } = filterTimelineRowsForPrincipal( - rows, - "p1", - 100, + it("denies a peer without a matching grant", async () => { + const { events, withheld } = await filterTimelineRows( + [row({ createdByPrincipalId: "owner", accessTags: ["knowledge.tenant:t1"] })], + { + principalId: "peer", + tenantId: "t1", + grants: createInMemoryGrantStore([]), + }, ); - expect(unreadableIds).toEqual(["bad"]); - expect(events.map((e) => e.title)).toEqual(["visible"]); + expect(withheld).toBe(1); + expect(events).toHaveLength(0); }); - it("fail-closes on native non-string acl_block (the pre-#9 fail-open hole)", () => { - const rows: TimelineRow[] = [ - row({ - id: "num", - title: "must not leak", - attributes: { acl_block: 42 }, - }), - row({ - id: "native-block", - title: "native array blocked", - attributes: { acl_block: ["p1"] }, - }), - row({ id: "ok", title: "visible" }), - ]; - const { events, unreadableIds } = filterTimelineRowsForPrincipal( - rows, - "p1", - 100, + it("allows a peer with a grant on an access tag", async () => { + const grants = createInMemoryGrantStore([ + { + id: "g1", + principalId: "peer", + resource: "knowledge.tenant:t1", + action: "find", + effect: "allow", + origin: "role", + conditions: null, + expiresAt: null, + roleId: null, + }, + ]); + const { events, withheld } = await filterTimelineRows( + [ + row({ + createdByPrincipalId: "owner", + accessTags: ["knowledge.tenant:t1"], + }), + ], + { principalId: "peer", tenantId: "t1", grants }, ); - expect(unreadableIds).toEqual(["num"]); - expect(events.map((e) => e.title)).toEqual(["visible"]); - }); - - it("maps wire fields from durable row columns (lastSeenAt → at, adapter → source)", () => { - const rows: TimelineRow[] = [ - row({ - id: "1", - title: "note", - adapter: "mcp", - principalId: "alice", - lastSeenAt: new Date("2026-06-01T12:00:00.000Z"), - }), - ]; - const { events } = filterTimelineRowsForPrincipal(rows, "p1", 100); - expect(events[0]).toEqual({ - at: "2026-06-01T12:00:00.000Z", - title: "note", - source: "mcp", - tenantId: "t1", - principalId: "alice", - }); - }); - - it("respects the limit after filtering", () => { - const rows: TimelineRow[] = [ - row({ - id: "blocked", - title: "blocked", - attributes: { acl_block: JSON.stringify(["p1"]) }, - }), - row({ id: "a", title: "a" }), - row({ id: "b", title: "b" }), - ]; - const { events } = filterTimelineRowsForPrincipal(rows, "p1", 1); + expect(withheld).toBe(0); expect(events).toHaveLength(1); - expect(events[0]?.title).toBe("a"); }); - it("uses empty string when version principal is null", () => { - const rows: TimelineRow[] = [ - row({ id: "1", title: "sys", principalId: null }), - ]; - const { events } = filterTimelineRowsForPrincipal(rows, "p1", 100); - expect(events[0]?.principalId).toBe(""); - }); -}); - -describe("listTimelineEvents (production path via mock db)", () => { - it("applies acl_block post-filter so blocked titles never reach the wire", async () => { - const SECRET = "Q3 layoffs — draft list"; - const rows: TimelineRow[] = [ - row({ id: "1", title: "team standup notes" }), - row({ - id: "2", - title: SECRET, - attributes: { acl_block: JSON.stringify(["p1"]) }, - }), - row({ - id: "3", - title: "corrupt-should-not-leak", - attributes: { acl_block: "{not-json" }, - }), - row({ - id: "4", - title: "non-string-should-not-leak", - attributes: { acl_block: 42 }, - }), - ]; - const mock = mockDb(rows); - const events = await listTimelineEvents({ - db: mock.db, - tenantId: "t1", - principalId: "p1", - limit: 10, - }); - expect(events.map((e) => e.title)).toEqual(["team standup notes"]); - expect(events.map((e) => e.title)).not.toContain(SECRET); - expect(events.map((e) => e.title)).not.toContain("corrupt-should-not-leak"); - expect(events.map((e) => e.title)).not.toContain( - "non-string-should-not-leak", + it("creator-only when no GrantStore is mounted", async () => { + const { events, withheld } = await filterTimelineRows( + [ + row({ documentId: "a", createdByPrincipalId: "p1", title: "mine" }), + row({ documentId: "b", createdByPrincipalId: "other", title: "theirs" }), + ], + { principalId: "p1", tenantId: "t1" }, ); - // Small limit still overfetches at least DEFAULT_TIMELINE_LIMIT candidates. - expect(mock.limitSeen).toBe(DEFAULT_TIMELINE_LIMIT); - }); - - it("caps page size at the requested limit after filtering", async () => { - const rows: TimelineRow[] = Array.from({ length: 5 }, (_, i) => - row({ id: String(i), title: `doc-${i}` }), - ); - const mock = mockDb(rows); - const events = await listTimelineEvents({ - db: mock.db, - tenantId: "t1", - principalId: "p1", - limit: 2, - }); - expect(events).toHaveLength(2); - expect(events.map((e) => e.title)).toEqual(["doc-0", "doc-1"]); + expect(withheld).toBe(1); + expect(events.map((e) => e.title)).toEqual(["mine"]); }); }); -describe("listTimelineEvents SQL composition (shared with search)", () => { - it("timelineWhere attaches tenant + the same visibilityPredicateSql as search", () => { - const fragment = timelineWhere("t1", "principal_a"); - const { sql, params } = dialect.sqlToQuery(fragment!); +describe("timelineWhere", () => { + it("is tenant-only (no visibility mini-ACL in SQL)", () => { + const { sql, params } = dialect.sqlToQuery(timelineWhere("tenant_a")); expect(sql).toContain("tenant_id"); - expect(sql).toContain("visibility_mode"); - expect(sql).toContain("'tenant'"); - expect(sql).toContain("'principals', 'private'"); - expect(sql).toContain("visibility_principal_ids"); - expect(params).toContain("t1"); - expect(params).toContain(JSON.stringify(["principal_a"])); - }); - - it("timelineWhere with a principal never matches private/allowlist docs for others", () => { - // Regression: private/principals visibility requires principal_ids @> [caller]. - // A second principal must not satisfy that containment for the first's private doc. - const forAlice = dialect.sqlToQuery(timelineWhere("t1", "alice")!); - const forBob = dialect.sqlToQuery(timelineWhere("t1", "bob")!); - expect(forAlice.params).toContain(JSON.stringify(["alice"])); - expect(forBob.params).toContain(JSON.stringify(["bob"])); - expect(forAlice.params).not.toContain(JSON.stringify(["bob"])); - // Both include the principals/private branch (not tenant-only null-principal shape). - expect(forAlice.sql).toContain("'principals', 'private'"); - expect(forBob.sql).toContain("'principals', 'private'"); - }); - - it("timelineActiveVersionJoin pins active + live generation", () => { - const fragment = timelineActiveVersionJoin(); - const { sql, params } = dialect.sqlToQuery(fragment!); - expect(sql).toContain("document_id"); - expect(sql).toContain("status"); - expect(sql).toContain("generation"); - expect(params).toContain("active"); - expect(params).toContain(LIVE_GENERATION); - }); - - it("orders by last_seen_at so re-captures rise in the timeline", () => { - const { sql } = dialect.sqlToQuery(desc(knowledgeDocument.lastSeenAt)); - expect(sql).toContain("last_seen_at"); + expect(params).toContain("tenant_a"); + expect(sql).not.toContain("visibility_mode"); + expect(sql).not.toContain("visibility_principal_ids"); }); }); diff --git a/src/services/timeline.ts b/src/services/timeline.ts index 3c73567..853c51b 100644 --- a/src/services/timeline.ts +++ b/src/services/timeline.ts @@ -1,15 +1,18 @@ /** - * Durable capture timeline: recent knowledge_document rows for a principal, - * filtered with the same visibility SQL and acl_block post-filter as search. + * Timeline / recent — tenant-scoped document history. + * + * Document access is Interchange grant tags (same as find): creator always + * sees own docs; otherwise any accessTag that authorize(..., tag, "find") + * allows. No visibility SQL, no acl_block post-filter. + * + * See docs/AUTHZ-DOCUMENT-ACCESS.md. */ -import { and, desc, eq, type SQL } from "drizzle-orm"; - -import { readBlockList } from "../acl.ts"; -import { LIVE_GENERATION } from "../core/generation.ts"; +import { and, desc, eq, sql } from "drizzle-orm"; +import type { ConditionRegistry, GrantStore } from "@intx/authz"; +import { canAccessDocument } from "../acl.ts"; import type { Db } from "../db/client.ts"; import { knowledgeDocument, knowledgeVersion } from "../db/schema.ts"; import { log } from "../log.ts"; -import { visibilityPredicateSql } from "./search.ts"; export type TimelineEvent = { at: string; @@ -19,165 +22,137 @@ export type TimelineEvent = { principalId: string; }; -export type TimelineRow = { - id: string; - title: string; - tenantId: string; - adapter: string; - /** Activity time used for ordering and the wire `at` field (last_seen_at). */ - lastSeenAt: Date; - attributes: unknown; - principalId: string | null; -}; - export type ListTimelineParams = { db: Db; tenantId: string; principalId: string; limit?: number; + /** Host grant store — required for non-creator document access. */ + grants?: GrantStore; + conditionRegistry?: ConditionRegistry; }; -export const DEFAULT_TIMELINE_LIMIT = 100; - -/** - * Active live-generation version join. Exported so unit tests pin the join - * predicates without a live Postgres. - */ -export function timelineActiveVersionJoin(): SQL | undefined { - return and( - eq(knowledgeVersion.documentId, knowledgeDocument.id), - eq(knowledgeVersion.status, "active"), - eq(knowledgeVersion.generation, LIVE_GENERATION), - ); -} +export type TimelineRow = { + documentId: string; + title: string; + adapter: string; + externalRef: string; + occurredAt: Date; + createdByPrincipalId: string | null; + accessTags: string[] | null; +}; -/** - * Tenant + visibility WHERE clause — same visibilityPredicateSql as search. - * Exported so unit tests pin the SQL composition to listTimelineEvents. - */ -export function timelineWhere( - tenantId: string, - principalId: string, -): SQL | undefined { - return and( - eq(knowledgeDocument.tenantId, tenantId), - visibilityPredicateSql(principalId), - ); -} +/** Over-fetch factor before grant-tag filter (same idea as hybrid overfetch). */ +const TIMELINE_OVERFETCH = 4; +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 100; /** - * Whether a timeline row is withheld from `principalId` under the same - * fail-closed `readBlockList` rules search uses via `blockedDocumentIds`. - * - * Returns why so callers can log unreadable ACLs without inventing a second - * membership interpretation. + * Tenant-only WHERE — document access is applied in application code. */ -export function timelineRowBlock( - attributes: Record | null, - principalId: string, -): "allow" | "blocked" | "unreadable" { - const read = readBlockList(attributes?.["acl_block"]); - if (read.kind === "absent") return "allow"; - if (read.kind === "unreadable") return "unreadable"; - return read.principalIds.includes(principalId) ? "blocked" : "allow"; +export function timelineWhere(tenantId: string) { + return eq(knowledgeDocument.tenantId, tenantId); } /** - * Pure block post-filter used by listTimelineEvents. Exported for unit tests - * so the leak guard does not need a live Postgres. - * - * Rows that fail closed (unreadable acl_block) are dropped; their ids are - * returned in `unreadableIds` for capped audit logging (same posture as search). + * Filter raw timeline rows to those the principal may see under grant tags. */ -export function filterTimelineRowsForPrincipal( +export async function filterTimelineRows( rows: readonly TimelineRow[], - principalId: string, - limit: number, -): { events: TimelineEvent[]; unreadableIds: string[] } { + params: { + principalId: string; + tenantId: string; + grants?: GrantStore; + conditionRegistry?: ConditionRegistry; + }, +): Promise<{ events: TimelineEvent[]; withheld: number }> { const events: TimelineEvent[] = []; - const unreadableIds: string[] = []; + let withheld = 0; + for (const row of rows) { - if (events.length >= limit) break; - const attributes = - row.attributes && typeof row.attributes === "object" - ? (row.attributes as Record) - : null; - // Same gate as search: readBlockList fail-closed membership. - const decision = timelineRowBlock(attributes, principalId); - if (decision === "unreadable") { - unreadableIds.push(row.id); - continue; + if (!params.grants) { + // Safe default: creator-only when no GrantStore is mounted. + if (row.createdByPrincipalId !== params.principalId) { + withheld += 1; + continue; + } + } else { + const ok = await canAccessDocument({ + grants: params.grants, + tenantId: params.tenantId, + principalId: params.principalId, + createdByPrincipalId: row.createdByPrincipalId, + accessTags: row.accessTags ?? [], + ...(params.conditionRegistry !== undefined + ? { conditionRegistry: params.conditionRegistry } + : {}), + }); + if (!ok) { + withheld += 1; + continue; + } } - if (decision === "blocked") continue; + events.push({ - at: row.lastSeenAt.toISOString(), + at: row.occurredAt.toISOString(), title: row.title, - source: row.adapter, - tenantId: row.tenantId, - principalId: row.principalId ?? "", + source: `${row.adapter}:${row.externalRef}`, + tenantId: params.tenantId, + principalId: params.principalId, }); } - return { events, unreadableIds }; + + return { events, withheld }; } /** - * Recent captures visible to `principalId` under the same ACL rules as search. - * Visibility is applied in SQL; acl_block is a post-filter via `readBlockList` - * (the same helper search uses through `blockedDocumentIds`). - * - * One row per document (active live version), ordered by last_seen_at DESC. - * Wire `source` is the document adapter (HTTP add defaults to "http"). - * Wire `principalId` is knowledge_version.created_by_principal_id. + * List recent document events for a tenant, filtered by grant-tag access. */ export async function listTimelineEvents( params: ListTimelineParams, ): Promise { const limit = Math.min( - Math.max(params.limit ?? DEFAULT_TIMELINE_LIMIT, 1), - DEFAULT_TIMELINE_LIMIT, - ); - // Overfetch so silent block drops still leave a full page when possible. - // Always scan at least DEFAULT_TIMELINE_LIMIT candidates so a small `limit` - // is not starved by a dense recent block list. - const overfetch = Math.min( - Math.max(limit * 3, DEFAULT_TIMELINE_LIMIT), - DEFAULT_TIMELINE_LIMIT * 3, + Math.max(params.limit ?? DEFAULT_LIMIT, 1), + MAX_LIMIT, ); + const fetchLimit = Math.min(limit * TIMELINE_OVERFETCH, MAX_LIMIT * TIMELINE_OVERFETCH); const rows = await params.db .select({ - id: knowledgeDocument.id, + documentId: knowledgeDocument.id, title: knowledgeDocument.title, - tenantId: knowledgeDocument.tenantId, adapter: knowledgeDocument.adapter, - lastSeenAt: knowledgeDocument.lastSeenAt, - attributes: knowledgeDocument.attributes, - principalId: knowledgeVersion.createdByPrincipalId, + externalRef: knowledgeDocument.externalRef, + occurredAt: knowledgeVersion.occurredAt, + createdByPrincipalId: knowledgeVersion.createdByPrincipalId, + accessTags: knowledgeDocument.accessTags, }) .from(knowledgeDocument) - .innerJoin(knowledgeVersion, timelineActiveVersionJoin()) - .where(timelineWhere(params.tenantId, params.principalId)) - .orderBy(desc(knowledgeDocument.lastSeenAt)) - .limit(overfetch); + .innerJoin( + knowledgeVersion, + and( + eq(knowledgeVersion.documentId, knowledgeDocument.id), + eq(knowledgeVersion.status, "active"), + ), + ) + .where(timelineWhere(params.tenantId)) + .orderBy(desc(knowledgeVersion.occurredAt)) + .limit(fetchLimit); - const { events, unreadableIds } = filterTimelineRowsForPrincipal( - rows, - params.principalId, - limit, - ); + const { events, withheld } = await filterTimelineRows(rows, { + principalId: params.principalId, + tenantId: params.tenantId, + ...(params.grants !== undefined ? { grants: params.grants } : {}), + ...(params.conditionRegistry !== undefined + ? { conditionRegistry: params.conditionRegistry } + : {}), + }); - if (unreadableIds.length > 0) { - const sampleLimit = 20; - const documentIds = unreadableIds.slice(0, sampleLimit); - const more = - unreadableIds.length > sampleLimit - ? ` (+${unreadableIds.length - sampleLimit} more)` - : ""; - log.warn( - `timeline: ${unreadableIds.length} document(s) had an unreadable acl_block; withholding: ${documentIds.join(", ")}${more}`, - { count: unreadableIds.length, documentIds }, + if (withheld > 0) { + log.info( + `timeline: withheld ${withheld} document(s) under grant-tag access for principal ${params.principalId}`, ); } - return events; + return events.slice(0, limit); }