@panaversity/ksor 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +6 -4
  3. package/dist/cli.mjs +6348 -8
  4. package/dist/index.d.mts +3 -2
  5. package/dist/index.mjs +1 -36
  6. package/dist/src-CpDIVudJ.mjs +43 -0
  7. package/docs/index.md +10 -4
  8. package/package.json +18 -5
  9. package/schema/schema.sql +316 -0
  10. package/templates/scaffold/.agents/skills/add-sources/SKILL.md +4 -1
  11. package/templates/scaffold/.agents/skills/format-checker/SKILL.md +8 -1
  12. package/templates/scaffold/.agents/skills/format-checker/check.mjs +241 -9
  13. package/templates/scaffold/.agents/skills/intake-interview/SKILL.md +27 -7
  14. package/templates/scaffold/.claude/skills/add-sources/SKILL.md +4 -1
  15. package/templates/scaffold/.claude/skills/format-checker/SKILL.md +8 -1
  16. package/templates/scaffold/.claude/skills/format-checker/check.mjs +241 -9
  17. package/templates/scaffold/.claude/skills/intake-interview/SKILL.md +27 -7
  18. package/templates/scaffold/AGENTS.md +137 -14
  19. package/templates/scaffold/README.md +55 -3
  20. package/templates/scaffold/gitignore +3 -0
  21. package/templates/scaffold/instance.md +3 -2
  22. package/templates/scaffold/package.json +8 -1
  23. package/templates/scaffold/pnpm-workspace.yaml +21 -5
  24. package/templates/scaffold/system/site/app/(home)/page.tsx +2 -2
  25. package/templates/scaffold/system/site/app/docs/layout.tsx +2 -2
  26. package/templates/scaffold/system/site/components/footer-mark.tsx +22 -0
  27. package/templates/scaffold/system/site/lib/audience.ts +178 -0
  28. package/templates/scaffold/system/site/lib/shared.ts +14 -5
  29. package/templates/scaffold/system/site/lib/stage-knowledge.ts +301 -0
  30. package/templates/scaffold/system/site/source.config.ts +7 -1
  31. package/templates/scaffold/vercel.json +8 -0
package/dist/index.d.mts CHANGED
@@ -19,8 +19,9 @@ declare const exitCodes: {
19
19
  readonly environment: 3;
20
20
  };
21
21
  type ExitCode = (typeof exitCodes)[keyof typeof exitCodes];
22
- /** The CLI vocabulary. Deliberately small; see the README. */
23
- declare const verbs: readonly ["init", "dev", "build", "serve"];
22
+ /** The CLI vocabulary. Lifecycle verbs plus the corpus operations the bundled
23
+ * kernel provides (one binary decision 12 publish revision). */
24
+ declare const verbs: readonly ["init", "dev", "build", "serve", "ingest", "schema", "grant", "calibrate", "gc"];
24
25
  type Verb = (typeof verbs)[number];
25
26
  interface ResolvedCommand {
26
27
  /** The first non-flag token, or null when only flags (or nothing) appear. */
package/dist/index.mjs CHANGED
@@ -1,37 +1,2 @@
1
- //#region src/index.ts
2
- /**
3
- * Public entry point of @panaversity/ksor.
4
- *
5
- * Nothing here is a released capability: 0.x builds expose only the CLI
6
- * contract, so that scripts and agents driving `ksor` can rely on stable,
7
- * documented exit semantics before the verbs are implemented.
8
- */
9
- /**
10
- * Exit-code contract for the `ksor` CLI. 2 must never be read as a crash: it
11
- * is the honest "this verb is designed but not implemented in this build".
12
- */
13
- const exitCodes = {
14
- /** The command was refused: bad input or a guarded operation. */
15
- refused: 1,
16
- /** The verb exists in the design but is not implemented in this build. */
17
- notImplemented: 2,
18
- /** The environment cannot run ksor (missing runtime requirement). */
19
- environment: 3
20
- };
21
- /** The CLI vocabulary. Deliberately small; see the README. */
22
- const verbs = [
23
- "init",
24
- "dev",
25
- "build",
26
- "serve"
27
- ];
28
- /** Resolve the command word from CLI arguments (flags are skipped). */
29
- function resolveCommand(argv) {
30
- const word = argv.find((arg) => !arg.startsWith("-")) ?? null;
31
- return {
32
- word,
33
- verb: word !== null && verbs.includes(word) ? word : null
34
- };
35
- }
36
- //#endregion
1
+ import { n as resolveCommand, r as verbs, t as exitCodes } from "./src-CpDIVudJ.mjs";
37
2
  export { exitCodes, resolveCommand, verbs };
@@ -0,0 +1,43 @@
1
+ //#region src/index.ts
2
+ /**
3
+ * Public entry point of @panaversity/ksor.
4
+ *
5
+ * Nothing here is a released capability: 0.x builds expose only the CLI
6
+ * contract, so that scripts and agents driving `ksor` can rely on stable,
7
+ * documented exit semantics before the verbs are implemented.
8
+ */
9
+ /**
10
+ * Exit-code contract for the `ksor` CLI. 2 must never be read as a crash: it
11
+ * is the honest "this verb is designed but not implemented in this build".
12
+ */
13
+ const exitCodes = {
14
+ /** The command was refused: bad input or a guarded operation. */
15
+ refused: 1,
16
+ /** The verb exists in the design but is not implemented in this build. */
17
+ notImplemented: 2,
18
+ /** The environment cannot run ksor (missing runtime requirement). */
19
+ environment: 3
20
+ };
21
+ /** The CLI vocabulary. Lifecycle verbs plus the corpus operations the bundled
22
+ * kernel provides (one binary — decision 12 publish revision). */
23
+ const verbs = [
24
+ "init",
25
+ "dev",
26
+ "build",
27
+ "serve",
28
+ "ingest",
29
+ "schema",
30
+ "grant",
31
+ "calibrate",
32
+ "gc"
33
+ ];
34
+ /** Resolve the command word from CLI arguments (flags are skipped). */
35
+ function resolveCommand(argv) {
36
+ const word = argv.find((arg) => !arg.startsWith("-")) ?? null;
37
+ return {
38
+ word,
39
+ verb: word !== null && verbs.includes(word) ? word : null
40
+ };
41
+ }
42
+ //#endregion
43
+ export { resolveCommand as n, verbs as r, exitCodes as t };
package/docs/index.md CHANGED
@@ -24,8 +24,10 @@ instead of their training memory. The corpus grows with each implemented verb.
24
24
  at `http://localhost:3000`; `pnpm build` writes a fully static export to
25
25
  `system/site/out/`. `KSOR_BASE_PATH=/repo pnpm build` targets sub-path
26
26
  hosting.
27
- - The remaining verbs (`dev`, `build`, `serve`) are designed, not
28
- implemented: each prints an honest notice and exits `2`.
27
+ - `ksor serve` runs the MCP server over a built record (with
28
+ `ingest`/`schema`/`calibrate`/`gc`) the climbed rung, needing Postgres and
29
+ a provider key. Only `dev` and `build` remain designed, not implemented:
30
+ each prints an honest notice and exits `2`.
29
31
  - Exit codes are a contract: `1` refused (first stderr line is a stable
30
32
  slug such as `error: bad-name`, followed by a remedy), `2` designed but
31
33
  not implemented, `3` the environment cannot run ksor
@@ -40,8 +42,12 @@ Read the scaffold's own `AGENTS.md` first — it is the working contract.
40
42
  Knowledge lives in `knowledge/` and never inside the site; frontmatter uses
41
43
  a closed key set (`title` + `status` required); `pnpm check` explains any
42
44
  violation and how to fix it. Sidebar order is the governed `order:`
43
- frontmatter key — never `meta.json` or `sidebar_position`. The site shell
44
- at `system/site/` is replaceable behind a four-clause surface contract; a
45
+ frontmatter key — never `meta.json` or `sidebar_position`. If the
46
+ instance declares an `audiences:` model, documents may carry a
47
+ `visibility:` key and per-audience builds (`KSOR_AUDIENCE=<tier> pnpm
48
+ build`) stage only what that tier may see — publication, not authorship:
49
+ anyone who can clone reads everything. The site shell
50
+ at `system/site/` is replaceable behind a five-clause surface contract; a
45
51
  Docusaurus conformance shell lives in the ksor repository under
46
52
  `workbench/shells/docusaurus/` with its swap recipe.
47
53
 
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@panaversity/ksor",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Knowledge System of Record — the authoritative, governed source of knowledge that humans and AI agents operate from. Name reserved; implementation in progress.",
5
5
  "keywords": [
6
6
  "abstention",
7
7
  "citations",
8
8
  "documentation",
9
- "docusaurus",
9
+ "fumadocs",
10
10
  "governance",
11
11
  "knowledge-system-of-record",
12
12
  "mcp",
@@ -33,7 +33,8 @@
33
33
  "docs",
34
34
  "templates",
35
35
  "CHANGELOG.md",
36
- "NOTICE"
36
+ "NOTICE",
37
+ "schema"
37
38
  ],
38
39
  "type": "module",
39
40
  "sideEffects": false,
@@ -49,19 +50,31 @@
49
50
  "access": "public",
50
51
  "provenance": true
51
52
  },
53
+ "dependencies": {
54
+ "@google/genai": "^2.17.1",
55
+ "@hono/node-server": "2.1.1",
56
+ "@modelcontextprotocol/server": "^2.0.0",
57
+ "@types/pg": "^8.21.0",
58
+ "hono": "4.13.2",
59
+ "jose": "^6.2.9",
60
+ "pg": "^8.23.0",
61
+ "zod": "^4.4.3"
62
+ },
52
63
  "devDependencies": {
53
64
  "@types/node": "^24.13.3",
54
65
  "playwright": "^1.58.0",
55
66
  "publint": "0.3.23",
56
67
  "tsdown": "0.22.14",
57
68
  "typescript": "7.0.2",
58
- "vitest": "^4.1.10"
69
+ "vitest": "^4.1.10",
70
+ "@panaversity/ksor-content": "0.0.0",
71
+ "@panaversity/ksor-content-gateway": "0.0.0"
59
72
  },
60
73
  "engines": {
61
74
  "node": ">=24"
62
75
  },
63
76
  "scripts": {
64
- "build": "tsdown",
77
+ "build": "tsdown && node copy-schema.mjs",
65
78
  "typecheck": "tsc -p tsconfig.json",
66
79
  "publint": "publint"
67
80
  }
@@ -0,0 +1,316 @@
1
+ -- sor-content schema v2 — the generational corpus store (specs/platform/generations.md §2 is the
2
+ -- design this implements; specs/platform/spec.md §5 the roles; the legacy schema the quarry).
3
+ -- ONE schema file; no migration runner (spec §9: compatibility is a RANGE, recorded in schema_meta).
4
+ --
5
+ -- Carried from legacy verbatim where the eval lock demands it: HNSW (m=16, ef_construction=64)
6
+ -- cosine, 'english' generated tsvector, per-tenant one-embedding-model trigger, fail-closed tenant
7
+ -- RLS keyed on GUC app.tenant_id. The two embedding columns (chunks, node_centroids) carry the
8
+ -- dimension of the DECLARED embedding space (instance.md `embedding.dim`): `schema/schema.sql` in
9
+ -- the repo is the shipped, eval-locked rendering (Gemini Embedding 001 at its MRL truncation);
10
+ -- `sor_content.render_schema(dim)` / `sor-content-schema --instance <dir>` re-render it for another
11
+ -- declared space. A different space is a NEW database.
12
+ -- v2 decisions recorded here:
13
+ -- • generation BIGINT on every corpus table; natural uniques are GENERATION-SCOPED.
14
+ -- • status='published' filtering is an ARM PREDICATE on every serving path (resolving the legacy
15
+ -- open decision): drafts embed like everything else, serving hides them; RLS stays a pure
16
+ -- tenant wall.
17
+ -- • hnsw.iterative_scan = relaxed_order + pinned ef_search are RUNTIME SET LOCALs applied by the
18
+ -- query builder on every vector arm (the legacy documented-but-unapplied fix becomes code in 1b).
19
+ -- • takedown_denylist has NO generation column BY DESIGN (denial beats every generation, §6).
20
+
21
+ CREATE EXTENSION IF NOT EXISTS vector;
22
+
23
+ -- ============================================================================ corpus pointer + runs
24
+ CREATE TABLE corpora (
25
+ tenant_id TEXT NOT NULL,
26
+ corpus_id TEXT NOT NULL, -- immutable, globally unique (spec §4)
27
+ active_generation BIGINT NOT NULL,
28
+ rollback_generation BIGINT,
29
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
30
+ PRIMARY KEY (tenant_id, corpus_id)
31
+ );
32
+
33
+ CREATE TABLE ingestion_runs (
34
+ run_id BIGSERIAL PRIMARY KEY,
35
+ tenant_id TEXT NOT NULL,
36
+ corpus_id TEXT NOT NULL,
37
+ generation BIGINT NOT NULL,
38
+ state TEXT NOT NULL CHECK (state IN ('building','ready','active','retired','abandoned','reaped')),
39
+ source_commit TEXT NOT NULL,
40
+ instance_bundle_sha256 TEXT NOT NULL,
41
+ started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
42
+ heartbeat_at TIMESTAMPTZ NOT NULL DEFAULT now(),
43
+ finished_at TIMESTAMPTZ,
44
+ UNIQUE (tenant_id, corpus_id, generation)
45
+ );
46
+
47
+ -- ============================================================================ the tree
48
+ CREATE TABLE content_nodes (
49
+ node_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
50
+ tenant_id TEXT NOT NULL,
51
+ generation BIGINT NOT NULL,
52
+ stable_id TEXT NOT NULL, -- durable identity WITHIN the corpus (spec §7)
53
+ parent_id UUID,
54
+ kind TEXT NOT NULL,
55
+ slug TEXT NOT NULL,
56
+ title TEXT NOT NULL,
57
+ summary TEXT,
58
+ keywords TEXT[],
59
+ position INT NOT NULL DEFAULT 0,
60
+ permalink TEXT, -- CONFIRMED site route (/docs/…), sitemap-verified at publish; NULL = no proven page URL (a group, or an unlisted route) — never a guess
61
+ status TEXT NOT NULL DEFAULT 'published' CHECK (status IN ('published','draft','archived')),
62
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
63
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
64
+ CONSTRAINT nodes_stable_uniq UNIQUE (tenant_id, generation, stable_id),
65
+ CONSTRAINT nodes_sibling_uniq UNIQUE (tenant_id, generation, parent_id, slug),
66
+ CONSTRAINT nodes_id_tenant_uniq UNIQUE (node_id, tenant_id),
67
+ CONSTRAINT nodes_no_self_parent CHECK (parent_id IS NULL OR parent_id <> node_id),
68
+ CONSTRAINT nodes_parent_fk FOREIGN KEY (parent_id, tenant_id)
69
+ REFERENCES content_nodes (node_id, tenant_id) ON DELETE RESTRICT
70
+ );
71
+ CREATE INDEX idx_nodes_gen ON content_nodes (tenant_id, generation, kind);
72
+ CREATE INDEX idx_nodes_parent ON content_nodes (parent_id);
73
+ CREATE INDEX idx_nodes_keywords ON content_nodes USING gin (keywords);
74
+ CREATE UNIQUE INDEX nodes_root_slug_uniq ON content_nodes (tenant_id, generation, slug) WHERE parent_id IS NULL;
75
+
76
+ CREATE TABLE slug_aliases (
77
+ tenant_id TEXT NOT NULL,
78
+ generation BIGINT NOT NULL,
79
+ alias_slug TEXT NOT NULL,
80
+ canonical_slug TEXT NOT NULL, -- aliases FLATTEN at ingest (spec §7)
81
+ PRIMARY KEY (tenant_id, generation, alias_slug)
82
+ );
83
+
84
+ -- ============================================================================ files + passages
85
+ CREATE TABLE sources (
86
+ tenant_id TEXT NOT NULL,
87
+ generation BIGINT NOT NULL,
88
+ source_id TEXT NOT NULL, -- path-derived, unique within (tenant, generation)
89
+ node_id UUID NOT NULL,
90
+ modality TEXT NOT NULL DEFAULT 'prose',
91
+ title TEXT NOT NULL,
92
+ origin_path TEXT NOT NULL,
93
+ content_hash TEXT NOT NULL,
94
+ embedding_model TEXT NOT NULL,
95
+ chunk_policy TEXT NOT NULL,
96
+ source_commit TEXT,
97
+ seeded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
98
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
99
+ PRIMARY KEY (tenant_id, generation, source_id),
100
+ CONSTRAINT sources_node_fk FOREIGN KEY (node_id, tenant_id)
101
+ REFERENCES content_nodes (node_id, tenant_id) ON DELETE RESTRICT
102
+ );
103
+ CREATE INDEX idx_sources_node ON sources (node_id);
104
+
105
+ CREATE TABLE chunks (
106
+ chunk_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
107
+ tenant_id TEXT NOT NULL,
108
+ generation BIGINT NOT NULL,
109
+ source_id TEXT NOT NULL,
110
+ ordinal INT NOT NULL,
111
+ content TEXT NOT NULL,
112
+ chunk_hash TEXT NOT NULL, -- sha256(content): the carry-forward key (§3.4)
113
+ heading_path JSONB NOT NULL DEFAULT '[]',
114
+ heading_path_text TEXT,
115
+ anchor TEXT,
116
+ labels JSONB NOT NULL DEFAULT '{}',
117
+ embedding VECTOR(1536), -- NULL while pending/failed; dim = the declared space
118
+ embedding_status TEXT NOT NULL DEFAULT 'embedded'
119
+ CHECK (embedding_status IN ('pending','embedded','failed')),
120
+ search_tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('english', content)) STORED,
121
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
122
+ embedded_at TIMESTAMPTZ,
123
+ embedding_model TEXT,
124
+ embed_error TEXT,
125
+ UNIQUE (tenant_id, generation, source_id, ordinal),
126
+ CONSTRAINT chunks_embedded_has_vector CHECK (embedding_status <> 'embedded' OR embedding IS NOT NULL),
127
+ CONSTRAINT chunks_source_fk FOREIGN KEY (tenant_id, generation, source_id)
128
+ REFERENCES sources (tenant_id, generation, source_id) ON DELETE CASCADE
129
+ );
130
+ CREATE INDEX idx_chunks_hnsw ON chunks USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
131
+ CREATE INDEX idx_chunks_gen ON chunks (tenant_id, generation);
132
+ CREATE INDEX idx_chunks_hpath ON chunks (source_id, heading_path_text text_pattern_ops);
133
+ CREATE INDEX idx_chunks_pending ON chunks (tenant_id, generation, source_id) WHERE embedding_status <> 'embedded';
134
+ CREATE INDEX idx_chunks_tsv ON chunks USING gin (search_tsv);
135
+
136
+ -- Materialized at run finalization (generations.md §2): the routing arm reads rows, never
137
+ -- aggregates at query time; generation-consistent by construction.
138
+ CREATE TABLE node_centroids (
139
+ tenant_id TEXT NOT NULL,
140
+ generation BIGINT NOT NULL,
141
+ node_id UUID NOT NULL,
142
+ stable_id TEXT NOT NULL, -- denylist binds here without a join
143
+ chunk_count INT NOT NULL,
144
+ embedding VECTOR(1536) NOT NULL,
145
+ PRIMARY KEY (tenant_id, generation, node_id)
146
+ );
147
+
148
+ -- ============================================================================ takedown (NO generation)
149
+ -- scope (decision 14): 'node' denies EXACTLY this stable_id (identity — the
150
+ -- default, immune to reorganization, auditable as a frozen list); 'subtree'
151
+ -- denies this node AND every descendant, resolved at SERVING time by a
152
+ -- recursive parent_id walk (so descendants added by a FUTURE generation are
153
+ -- covered — this table has no generation column BY DESIGN). NO generation
154
+ -- column: denial beats every generation (§6).
155
+ CREATE TABLE takedown_denylist (
156
+ tenant_id TEXT NOT NULL,
157
+ corpus_id TEXT NOT NULL,
158
+ stable_id TEXT NOT NULL,
159
+ scope TEXT NOT NULL DEFAULT 'node' CHECK (scope IN ('node','subtree')),
160
+ reason TEXT NOT NULL,
161
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
162
+ PRIMARY KEY (tenant_id, corpus_id, stable_id)
163
+ );
164
+
165
+ -- ============================================================================ the ledger
166
+ CREATE TABLE retrieval_log (
167
+ id BIGSERIAL,
168
+ tenant_id TEXT NOT NULL,
169
+ corpus_id TEXT,
170
+ generation BIGINT,
171
+ actor TEXT NOT NULL, -- NO default: unset errors loudly (carried)
172
+ action TEXT NOT NULL CHECK (action IN
173
+ ('content_served','similarity_searched','corpus_seeded','outline_served',
174
+ 'search_abstained','generation_activated','takedown_applied')),
175
+ source_id TEXT,
176
+ -- spec §7 audit fields (explicit + queryable; free detail rides JSONB)
177
+ content_hash TEXT,
178
+ chunk_hash TEXT,
179
+ instance_bundle_sha256 TEXT,
180
+ embedding_model TEXT,
181
+ chunk_policy_version TEXT,
182
+ detail JSONB NOT NULL DEFAULT '{}',
183
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
184
+ PRIMARY KEY (id, created_at)
185
+ ) PARTITION BY RANGE (created_at);
186
+ CREATE TABLE retrieval_log_default PARTITION OF retrieval_log DEFAULT;
187
+ CREATE INDEX idx_rlog_tenant ON retrieval_log (tenant_id, created_at);
188
+ CREATE INDEX idx_rlog_action ON retrieval_log (action, created_at);
189
+
190
+ -- ============================================================================ meta + triggers
191
+ CREATE TABLE schema_meta (
192
+ schema_version TEXT NOT NULL, -- spec §9: readers/writers support a RANGE
193
+ compatible_from TEXT NOT NULL,
194
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
195
+ );
196
+ -- 2.1 adds takedown_denylist.scope (decision 14); additive with a default, so
197
+ -- a 2.0 reader still reads a 2.1 database — compatible_from stays 2.0.
198
+ INSERT INTO schema_meta (schema_version, compatible_from) VALUES ('2.1', '2.0');
199
+
200
+ CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS trigger AS $$
201
+ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$ LANGUAGE plpgsql;
202
+ CREATE TRIGGER trg_nodes_touch BEFORE UPDATE ON content_nodes FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
203
+ CREATE TRIGGER trg_sources_touch BEFORE UPDATE ON sources FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
204
+
205
+ -- One embedding model per tenant (carried verbatim — a model switch is a DELIBERATE full re-embed).
206
+ CREATE OR REPLACE FUNCTION sources_one_model() RETURNS trigger AS $$
207
+ BEGIN
208
+ IF EXISTS (SELECT 1 FROM sources WHERE tenant_id = NEW.tenant_id AND embedding_model <> NEW.embedding_model) THEN
209
+ RAISE EXCEPTION 'embedding-contract: tenant % is on model %, refusing % (a model switch is a deliberate full re-embed)',
210
+ NEW.tenant_id,
211
+ (SELECT embedding_model FROM sources WHERE tenant_id = NEW.tenant_id AND embedding_model <> NEW.embedding_model LIMIT 1),
212
+ NEW.embedding_model;
213
+ END IF;
214
+ RETURN NEW;
215
+ END; $$ LANGUAGE plpgsql;
216
+ CREATE TRIGGER trg_sources_one_model
217
+ BEFORE INSERT OR UPDATE OF embedding_model ON sources
218
+ FOR EACH ROW EXECUTE FUNCTION sources_one_model();
219
+
220
+ -- ============================================================================ roles (spec §5 — per-DB grants,
221
+ -- FORCE RLS, NO BYPASSRLS, no owner membership; the grant table IS ingest authorization)
222
+ CREATE TABLE ingest_tenant_grants (
223
+ role_name TEXT NOT NULL,
224
+ tenant_id TEXT NOT NULL,
225
+ granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
226
+ PRIMARY KEY (role_name, tenant_id)
227
+ );
228
+
229
+ DO $$ BEGIN
230
+ IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'sor_content_runtime') THEN CREATE ROLE sor_content_runtime NOLOGIN; END IF;
231
+ IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'sor_content_ingest') THEN CREATE ROLE sor_content_ingest NOLOGIN; END IF;
232
+ END $$;
233
+
234
+ -- Explicit in-schema membership for the APPLYING role, so SET LOCAL ROLE works from day one
235
+ -- (spec §5: NO out-of-band grants — the legacy schema's missing owner-membership grant broke
236
+ -- every fresh project until hand-fixed). Deployment LOGIN roles receive the same membership
237
+ -- when they are provisioned (1h).
238
+ DO $$ BEGIN
239
+ EXECUTE format('GRANT sor_content_runtime, sor_content_ingest TO %I WITH SET TRUE', current_user);
240
+ END $$;
241
+
242
+ GRANT USAGE ON SCHEMA public TO sor_content_runtime, sor_content_ingest;
243
+ -- runtime: read published corpora, write the ledger — NOTHING else.
244
+ GRANT SELECT ON corpora, content_nodes, sources, chunks, slug_aliases, node_centroids, takedown_denylist, schema_meta TO sor_content_runtime;
245
+ -- freshness on /health (2026-07-16): the runtime reads the active run's source_commit +
246
+ -- finished_at/heartbeat_at to report sync-age; RLS tenant_read already covers ingestion_runs.
247
+ GRANT SELECT ON ingestion_runs TO sor_content_runtime;
248
+ GRANT INSERT ON retrieval_log TO sor_content_runtime;
249
+ GRANT USAGE, SELECT ON SEQUENCE retrieval_log_id_seq TO sor_content_runtime;
250
+ -- ingest: build generations + flip, for AUTHORIZED tenants only (policy-checked via the grant table).
251
+ GRANT SELECT ON schema_meta, takedown_denylist, ingest_tenant_grants TO sor_content_ingest;
252
+ GRANT SELECT, INSERT, UPDATE, DELETE ON corpora, ingestion_runs, content_nodes, sources, chunks, slug_aliases, node_centroids TO sor_content_ingest;
253
+ GRANT INSERT ON retrieval_log TO sor_content_ingest;
254
+ GRANT USAGE, SELECT ON SEQUENCE retrieval_log_id_seq, ingestion_runs_run_id_seq TO sor_content_ingest;
255
+
256
+ -- FORCE: even a table owner obeys (spec §5 hardened over legacy ENABLE).
257
+ ALTER TABLE corpora ENABLE ROW LEVEL SECURITY; ALTER TABLE corpora FORCE ROW LEVEL SECURITY;
258
+ ALTER TABLE ingestion_runs ENABLE ROW LEVEL SECURITY; ALTER TABLE ingestion_runs FORCE ROW LEVEL SECURITY;
259
+ ALTER TABLE content_nodes ENABLE ROW LEVEL SECURITY; ALTER TABLE content_nodes FORCE ROW LEVEL SECURITY;
260
+ ALTER TABLE sources ENABLE ROW LEVEL SECURITY; ALTER TABLE sources FORCE ROW LEVEL SECURITY;
261
+ ALTER TABLE chunks ENABLE ROW LEVEL SECURITY; ALTER TABLE chunks FORCE ROW LEVEL SECURITY;
262
+ ALTER TABLE slug_aliases ENABLE ROW LEVEL SECURITY; ALTER TABLE slug_aliases FORCE ROW LEVEL SECURITY;
263
+ ALTER TABLE node_centroids ENABLE ROW LEVEL SECURITY; ALTER TABLE node_centroids FORCE ROW LEVEL SECURITY;
264
+ ALTER TABLE retrieval_log ENABLE ROW LEVEL SECURITY; ALTER TABLE retrieval_log FORCE ROW LEVEL SECURITY;
265
+ ALTER TABLE takedown_denylist ENABLE ROW LEVEL SECURITY; ALTER TABLE takedown_denylist FORCE ROW LEVEL SECURITY;
266
+
267
+ -- Tenant wall (GUC app.tenant_id; unset ⇒ zero rows, fail-closed). Reads:
268
+ CREATE POLICY tenant_read ON corpora FOR SELECT USING (tenant_id = current_setting('app.tenant_id', true));
269
+ CREATE POLICY tenant_read ON content_nodes FOR SELECT USING (tenant_id = current_setting('app.tenant_id', true));
270
+ CREATE POLICY tenant_read ON sources FOR SELECT USING (tenant_id = current_setting('app.tenant_id', true));
271
+ CREATE POLICY tenant_read ON chunks FOR SELECT USING (tenant_id = current_setting('app.tenant_id', true));
272
+ CREATE POLICY tenant_read ON slug_aliases FOR SELECT USING (tenant_id = current_setting('app.tenant_id', true));
273
+ CREATE POLICY tenant_read ON node_centroids FOR SELECT USING (tenant_id = current_setting('app.tenant_id', true));
274
+ CREATE POLICY tenant_read ON takedown_denylist FOR SELECT USING (tenant_id = current_setting('app.tenant_id', true));
275
+ CREATE POLICY tenant_read ON ingestion_runs FOR SELECT USING (tenant_id = current_setting('app.tenant_id', true));
276
+ -- Ledger writes:
277
+ CREATE POLICY tenant_write ON retrieval_log FOR INSERT
278
+ WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
279
+ -- Ingest mutations: tenant GUC match AND the grant table authorizes THIS role for THIS tenant
280
+ -- (a CLI flag is not authorization — spec §5).
281
+ CREATE POLICY ingest_write ON content_nodes FOR ALL TO sor_content_ingest
282
+ USING (tenant_id = current_setting('app.tenant_id', true)
283
+ AND EXISTS (SELECT 1 FROM ingest_tenant_grants g WHERE g.role_name = current_user AND g.tenant_id = content_nodes.tenant_id))
284
+ WITH CHECK (tenant_id = current_setting('app.tenant_id', true)
285
+ AND EXISTS (SELECT 1 FROM ingest_tenant_grants g WHERE g.role_name = current_user AND g.tenant_id = content_nodes.tenant_id));
286
+ CREATE POLICY ingest_write ON sources FOR ALL TO sor_content_ingest
287
+ USING (tenant_id = current_setting('app.tenant_id', true)
288
+ AND EXISTS (SELECT 1 FROM ingest_tenant_grants g WHERE g.role_name = current_user AND g.tenant_id = sources.tenant_id))
289
+ WITH CHECK (tenant_id = current_setting('app.tenant_id', true)
290
+ AND EXISTS (SELECT 1 FROM ingest_tenant_grants g WHERE g.role_name = current_user AND g.tenant_id = sources.tenant_id));
291
+ CREATE POLICY ingest_write ON chunks FOR ALL TO sor_content_ingest
292
+ USING (tenant_id = current_setting('app.tenant_id', true)
293
+ AND EXISTS (SELECT 1 FROM ingest_tenant_grants g WHERE g.role_name = current_user AND g.tenant_id = chunks.tenant_id))
294
+ WITH CHECK (tenant_id = current_setting('app.tenant_id', true)
295
+ AND EXISTS (SELECT 1 FROM ingest_tenant_grants g WHERE g.role_name = current_user AND g.tenant_id = chunks.tenant_id));
296
+ CREATE POLICY ingest_write ON slug_aliases FOR ALL TO sor_content_ingest
297
+ USING (tenant_id = current_setting('app.tenant_id', true)
298
+ AND EXISTS (SELECT 1 FROM ingest_tenant_grants g WHERE g.role_name = current_user AND g.tenant_id = slug_aliases.tenant_id))
299
+ WITH CHECK (tenant_id = current_setting('app.tenant_id', true)
300
+ AND EXISTS (SELECT 1 FROM ingest_tenant_grants g WHERE g.role_name = current_user AND g.tenant_id = slug_aliases.tenant_id));
301
+ CREATE POLICY ingest_write ON node_centroids FOR ALL TO sor_content_ingest
302
+ USING (tenant_id = current_setting('app.tenant_id', true)
303
+ AND EXISTS (SELECT 1 FROM ingest_tenant_grants g WHERE g.role_name = current_user AND g.tenant_id = node_centroids.tenant_id))
304
+ WITH CHECK (tenant_id = current_setting('app.tenant_id', true)
305
+ AND EXISTS (SELECT 1 FROM ingest_tenant_grants g WHERE g.role_name = current_user AND g.tenant_id = node_centroids.tenant_id));
306
+ CREATE POLICY ingest_write ON ingestion_runs FOR ALL TO sor_content_ingest
307
+ USING (tenant_id = current_setting('app.tenant_id', true)
308
+ AND EXISTS (SELECT 1 FROM ingest_tenant_grants g WHERE g.role_name = current_user AND g.tenant_id = ingestion_runs.tenant_id))
309
+ WITH CHECK (tenant_id = current_setting('app.tenant_id', true)
310
+ AND EXISTS (SELECT 1 FROM ingest_tenant_grants g WHERE g.role_name = current_user AND g.tenant_id = ingestion_runs.tenant_id));
311
+ -- The FLIP (and only the flip-shaped mutation) on corpora:
312
+ CREATE POLICY ingest_flip ON corpora FOR ALL TO sor_content_ingest
313
+ USING (tenant_id = current_setting('app.tenant_id', true)
314
+ AND EXISTS (SELECT 1 FROM ingest_tenant_grants g WHERE g.role_name = current_user AND g.tenant_id = corpora.tenant_id))
315
+ WITH CHECK (tenant_id = current_setting('app.tenant_id', true)
316
+ AND EXISTS (SELECT 1 FROM ingest_tenant_grants g WHERE g.role_name = current_user AND g.tenant_id = corpora.tenant_id));
@@ -2,7 +2,7 @@
2
2
  name: add-sources
3
3
  description: Turn source material — documents, pages, pasted text, notes — into governed knowledge in knowledge/. Use when the owner shares material to add, says "add this to the knowledge base", or asks how to get existing content in. Not for editing the site.
4
4
  metadata:
5
- version: "1.1.0"
5
+ version: "1.2.0"
6
6
  ---
7
7
 
8
8
  # Add sources
@@ -24,6 +24,9 @@ The rules that make it _governed_ rather than merely stored:
24
24
  file names, systems, people, dates) whenever the owner can tell you.
25
25
  Precision matters: "Finance policy manual §4.2, 2025 edition" governs;
26
26
  "internal docs" does not.
27
+ - When `instance.md` declares `audiences:`, ask the owner which audience the
28
+ new material belongs to and write it as `visibility:` — never guess that
29
+ restricted material is public.
27
30
 
28
31
  ## Fidelity rules
29
32
 
@@ -2,7 +2,7 @@
2
2
  name: format-checker
3
3
  description: The record's format rules as a runnable check — frontmatter, filenames, links, structure. Use before handing off any change to knowledge/, when a check fails and you need to fix it, or when unsure whether a document is well-formed. Run with `pnpm check` (or node .agents/skills/format-checker/check.mjs).
4
4
  metadata:
5
- version: "1.1.0"
5
+ version: "1.2.0"
6
6
  ---
7
7
 
8
8
  # Format checker
@@ -25,6 +25,13 @@ enforces what AGENTS.md states in prose:
25
25
  blocks are code, not links, and are ignored.
26
26
  - `instance.md` exists, is `format: 1`, and carries only the keys the format
27
27
  defines — an unknown key is named, never ignored.
28
+ - The audience model, when there is one: `audiences:` is ordered least- to
29
+ most-restricted with `public` first, no duplicates, and never without
30
+ `default_visibility:`; a document's `visibility:` names one of the declared
31
+ audiences; and no link or `superseded_by:` points from a wider audience at a
32
+ narrower one — the leak no single build can catch, since the build that
33
+ publishes the pointer has already dropped its target. A record that declares
34
+ no `audiences:` is checked exactly as it was before the key existed.
28
35
  - `CLAUDE.md` stays a one-line pointer; `.agents/skills/` and
29
36
  `.claude/skills/` hold the same files byte for byte **in both directions**
30
37
  (a file only one tree carries is a rule nobody reviewed); the site contains