Groundnote — architecture and contract (Phase 1)#
Status: binding. This file is the authoritative English contract of the project: data model,
HTTP API, MCP surface and module layout. It replaces the German draft ("Wissensdienst",
datenmodell.md / api-vertrag.md) for this repository. German terms map to English identifiers
in glossary.md; every place where Groundnote departs from the German draft is in
abweichungen.md. Change this file first, then the code. The knowledge graph and agent guidance (built, slices G1–G8)
are specified in graph.md; see section 14. Approval modes A/B/C with their guardrails
(slice A, built) are specified in approval-modes.md; see section 15.1. How
agents use the MCP surface: agents.md. What is still open: section 15.
1. Invariants#
- Nothing is overwritten.
rule,rule_version,decision,source,source_attachment,source_segmentand (migration 0004)classification,source_auth,secret_findingare append-only by trigger (RAISE(ABORT, 'append-only')). Every change is a new version, every decision a new row. - Only the decision path confirms, and it has two entrances. Status
confirmed,superseded,rejectedis written only bysrc/decision/**. Person:decide()insrc/decision/decide.ts, reachable only fromPOST /v1/rules/:id/decision(approval channel, person). Policy:adopt()andretract()insrc/decision/policy/**;adopt()runs only insideintake()afterclassify()and knows onlyproposed → confirmedfor a checked quote, withoutsupersedes, without a mandatory exception and under the daily limit;retract()onlyconfirmed → supersededwhile the newestconfirm/reviewdecision is the policy's. Triggers enforce both (approval-modes.md §7).src/mcp/**reachessrc/decision/policy/**(propose, retract) but neverdecide.ts(AT1). No MCP tool confirms, rejects, revokes or reviews; the mode is set only ingroundnote.json. - The quote is evidence. For
jamieandmailthe quote is checked in code against the stored source (section 6.3). Not found →422 quote-not-found, nothing stored. The quote never changes after version 1 (trigger). - Superseding is one transaction (section 6.4).
- Validity is checked by the schema (CHECKs on
rule_version).valid_from/valid_untilare calendar days,said_at/changed_atare UTC instants ending inZ. - A file knows who it is. A scope file opens only if
meta.scopeequals its file name. - Loss locks, it does not answer. A scope file with fewer rule versions than
ops.sqliteremembers is locked (503 scope-locked). - Rejected never leaks. Outside the approval channel a rejected rule does not exist (
404), also withinclude_proposalsoras_of. - Secrets only from the environment, never in
groundnote.json, never logged. - Agents shape structure, persons confirm it (graph.md). Entities and links written via
mcporapitake effect at once asunconfirmedversions orproposedlinks; onlysrc/structure/**, reachable only from the approval channel, confirms, reverts or archives. - Foreign text reaches a model only framed. The
textcontent of every MCP result is rendered bysrc/security/model-output.ts: quotes, excerpts, subjects, names and every other field not on its server-field list stay inside untrusted blocks with a random boundary; a statement counts as human-confirmed only while its record isconfirmedby a person; an auto-adopted statement stays framed and is markedauto-adopted(section 9, approval-modes.md §9). - Guardrails are code, not configuration. The mandatory exceptions of approval-modes.md §5.4
apply in every mode; no config key turns one off, the validator refuses a config that names
one, and a secret in a proposal is refused (
422 secret-detected) before anything is stored.
2. Module layout#
| Path | Owns | May import |
|---|---|---|
src/config/ |
env schema (env.ts), groundnote.json schema (file.ts, approval.ts), startup refusals |
— |
src/db/ |
connection + pragmas, migration runner, migrations/scope/*.ts, migrations/ops/*.ts (SQL as exported strings), scope-store.ts (open, verify meta, lock, handle cache), ops-store.ts (grants, backup_state), fts.ts (reindexRule) |
config |
src/domain/ |
schemas.ts (shared Zod), ids.ts (ids, refs, scope from id), time.ts (zone, today, age), sources.ts, quote-check.ts, proposals.ts (only writer of status='proposed'), rules.ts (read paths, visibility), provenance.ts |
db, config, search |
src/decision/ |
decide.ts — the person path, only writer of rejected and rejected_fts; rejected-similar.ts; policy/** — the policy path (approval-modes.md §14): intake.ts, classify.ts, resolve.ts, sender.ts, ruleset.ts, adopt.ts, retract.ts. Together the only writers of confirmed/superseded and of decision rows |
db, domain, search, config; policy/** also security |
src/search/ |
schemas.ts (search request/response), stage-a.ts (wires the lexical module), lexical/ (the stage a module, docs/search/lexical.md), stage.ts (SearchStage), filter.ts, duplicates.ts (duplicate lookup of proposals, approval and decision), graph-links.ts (counted links), bundle.ts, line.ts, excerpt.ts, search.ts, feedback.ts; rows.ts, terms.ts, variants.ts, fts-query.ts, stopwords.ts are the single-index helpers |
db, domain/schemas, domain/ids, domain/time, graph (read) |
src/security/ |
untrusted.ts (units, boundary, escaping), model-output.ts (model-facing rendering of results, untrusted markers), injection-signals.ts (warning signals, never a gate) |
domain/ids, graph/ids |
src/backup/, src/export/ |
run.ts (backup run, 11.1), probe.ts (restore probe, 11.2), verify.ts, restore.ts, fetch.ts, compare.ts, inspect.ts, runner.ts, directory.ts (checked target directories, writeNew, tempPath), snapshot.ts, snapshot-worker.ts (consistent SQLite snapshot), schemas.ts (RunAnswer, ProbeAnswer, Check) (backup run, probe, verify and restore, section 11); export.ts, format.ts, tar.ts (section 4.4) |
config, db, domain (export only: db, domain) |
src/auth/ |
access-jwt.ts, credentials.ts, digest.ts, session.ts (dev session), service-bearer.ts, identity.ts, scope-check.ts (pipeline), csrf.ts, challenge.ts (WWW-Authenticate) |
config, db |
src/http/ |
app.ts (createApp(deps)), errors.ts (ApiError, codes, onError), routes/*.ts; routes/mcp.ts is the only file that imports src/mcp/server.ts |
all except mcp |
src/graph/ |
(graph.md) normalize.ts, resolve.ts, entities.ts (only writer of agent entity versions), closure.ts, fts.ts, tree.ts, links.ts (proposed links, computed links), guidance (path, next, remedy) |
db, config, domain |
src/structure/ |
(graph.md) review.ts — the only writer of review rows and of approval entity versions (confirm, revert, archive) |
db, domain, graph |
src/mcp/ |
server.ts (per-request McpServer named groundnote), instructions.ts, prompts.ts, context.ts, result.ts, tools/*.ts |
auth, domain, search, graph, decision/policy — never decision/decide.ts or structure |
src/approval/ |
server-rendered pages (hono/html, no client JS), dev login, "Structure changes" |
auth, domain, search, graph, decision, structure |
src/ops/ listener |
ops listener on a Unix socket (app.ts): scopes, grants, backup run/probe/verify, GET /ops/status (section 11); runs inside the service |
config, db, backup, export, ops/doctor (pure checks) |
src/ops/ CLI |
groundnote (cli.ts bin entry, cli-run.ts, cli-*.ts, cli.md) and doctor/ (checks.ts pure check functions, gatherers); cli.ts loads main.ts by dynamic import() for serve, the rest of ops never imports it |
config, backup (local restore), export (archive format), security/model-output, ops/doctor, and from domain/search/graph only the schema modules: domain/schemas.ts, domain/ids.ts, domain/source-schemas.ts, domain/proposal-schemas.ts, search/schemas.ts, graph/schemas.ts, graph/ids.ts, graph/page.ts, graph/config.ts, graph/link-types.ts — never decision, structure, approval, redaction, mcp, http, auth or a writer module |
src/main.ts |
wiring for groundnote serve |
all |
src/log.ts |
JSON-lines logger (ids, codes, counts only) | — |
createApp(deps) receives config, env, stores, a clock (now(): Date) and the JWT key resolver,
so tests run in-process (local RSA key + createLocalJWKSet).
Architecture tests (test/arch/*.test.ts, TypeScript compiler API, no extra dependency):
- AT1 the transitive relative-import closure of
src/mcp/**contains no file insrc/decision/**outsidesrc/decision/policy/**(slice A; AT1b keepspolicy/**itself away fromdecide.ts). - AT2
src/decision/**outsidepolicy/is imported only bysrc/http/routes/decision.tsandsrc/approval/**; AT2c/AT2d bound the importers ofpolicy/**and ofpropose(). - AT3 the strings
INSERT INTO rule_versionoccur only insrc/domain/proposals.ts(with the literal'proposed'),src/decision/decide.tsandsrc/decision/policy/{adopt,retract}.ts;INSERT INTO decisiononly there and insrc/db/migrations/**;rejected_ftsonly indecide.ts,rejected-similar.tsand migrations. - AT4 no registered MCP tool name matches
/confirm|reject|revoke|review|decide|approve/. - AT1b, AT2c, AT2d, AT6, AT7 and the exact wording of AT1–AT4 after slice A: approval-modes.md §14.
- AT1 also covers
src/structure/**; AT3 also covers the graph writers (graph.md §4); AT4 also covers prompt names and the wordsarchiveandrevert. - AT2b
src/structure/**is imported only bysrc/http/routes/structure.tsandsrc/approval/**. - AT5
entity_closureandentity_ftsare changed only by their writers and migrations (graph.md §13). - AT6 the static import closure of
src/ops/cli*.tsandsrc/ops/doctor/**contains no file ofsrc/decision/**,src/structure/**,src/approval/**,src/redaction/**,src/mcp/**,src/http/**,src/auth/**and none of the writer modulessrc/domain/proposals.ts,src/domain/sources.ts,src/graph/entities.ts,src/graph/links.ts,src/db/fts.ts; the only dynamicimport()insrc/ops/**is../main.jsinsrc/ops/cli.ts; and the CLI's route table (src/ops/cli-routes.ts, the only way the CLI builds an HTTP request) holds no approval-channel route exceptGET /v1/export/:scope. Prerequisite (one refactor commit, no behaviour change):SourceRequest,AttachmentIn,Segmentmove todomain/source-schemas.ts,ProposalRequest,ProposalSourcetodomain/proposal-schemas.ts,LINK_TYPES,PROPOSABLE_TYPEStograph/link-types.ts; callers import from there (no re-exports).
3. Configuration and environment#
groundnote.json (path GROUNDNOTE_CONFIG, default ./groundnote.json), validated strictly
(unknown keys fail start).
const ScopeName = z.string().regex(/^[a-z][a-z0-9-]{1,39}$/);
const Provider = z.enum(["local", "voyage", "openai", "jina", "typesafe"]);
const Off = z.object({ enabled: z.literal(false) }).strict();
const Semantic = z.discriminatedUnion("enabled", [Off, z.object({ // stage b (search/stages-b-c.md §8)
enabled: z.literal(true), model: z.enum(["bge-m3", "qwen3-embedding-0.6b"]).default("bge-m3"),
model_dir: z.string().min(1).optional() }).strict()]); // default <data dir>/models
const Reranker = z.discriminatedUnion("enabled", [Off, z.object({ // stage c; key TYPESAFE_API_KEY from the environment
enabled: z.literal(true), provider: z.literal("typesafe").default("typesafe"),
model: z.enum(["jev-1.14.0"]).default("jev-1.14.0"), monthly_cap_usd: z.number().min(0).max(1000).default(5),
timeout_ms: z.number().int().min(100).max(5000).default(1000), threshold: z.number().min(0).max(1).default(0.5) }).strict()]);
const Config = z.object({
instance: ScopeName,
zone: z.string().refine(isIanaZone), // e.g. "Europe/Berlin"
public_url: z.string().url(), // no trailing slash; https in cloudflare-access mode
scopes: z.array(z.object({ name: ScopeName, providers_allowed: z.array(Provider),
reranker: z.boolean().default(false) }).strict()) // c needs reranker + "typesafe"
.min(1).refine(uniqueNames), // what MAY exist; files come from `groundnote scope create`
access: z.object({ // required in cloudflare-access mode
team_domain: z.string().url(), // https://<team>.cloudflareaccess.com; JWKS = <team_domain>/cdn-cgi/access/certs
aud_mcp: z.string().min(1), aud_approval: z.string().min(1), aud_api: z.string().min(1),
}).strict().refine(distinctAuds).optional(),
search: z.object({
synonyms: z.array(z.array(z.string().min(1)).min(2)).default([]), // groups, case-insensitive
languages: z.array(z.enum(["de", "en"])).min(1).default(["de", "en"]),
lexical: LexicalConfig.prefault({}), // stage a switches and weights (below); every key optional
min_coverage: z.number().min(0).max(1).default(0), // 7.6a; 0 = off
semantic: Semantic.prefault({ enabled: false }), reranker: Reranker.prefault({ enabled: false }),
}).strict(),
backup: z.discriminatedUnion("kind", [
z.object({ kind: z.literal("litestream"), target: z.string().url(), prefix: z.string().min(1),
deadman_url: z.string().url(),
export_directory: AbsolutePath.optional() }).strict(), // default for single operators
z.object({ kind: z.literal("copy"), directory: z.string().min(1),
deadman_url: z.string().url(),
export_directory: AbsolutePath.optional() }).strict(), // consistent copy for an existing run (11.1)
]), // AbsolutePath = z.string().refine(isAbsolute)
at_rest: z.discriminatedUnion("kind", [ // optional until redaction R6 decides "required"
z.object({ kind: z.literal("volume"), attested_at: Day.optional() }).strict(),
]).optional(),
doctor: z.object({ // every key optional (cli.md §6)
max_backup_age_hours: z.number().int().min(1).max(24 * 14).default(26),
max_probe_age_hours: z.number().int().min(1).max(24 * 14).default(26),
}).strict().prefault({}),
recheck_after_days: z.number().int().min(30).max(730).default(180),
limits: z.object({ // in-process abuse limits (F1); every key optional
requests_per_minute: PerMinute(60), // per grant on /mcp and /v1 (except decisions)
decisions_per_minute: PerMinute(60), // per grant on decisions and structure reviews (approval)
login_attempts_per_minute: PerMinute(5), // per client address on POST /approval/login
exports_per_hour: z.number().int().min(1).max(10_000).default(6), // per grant on GET /v1/export/:scope
trusted_proxy: z.boolean().default(false), // true: the last X-Forwarded-For entry is the client
}).strict().prefault({}), // PerMinute(d) = z.number().int().min(1).max(100_000).default(d)
graph: z.object({ // graph.md §7; every key optional
entity_types: z.array(EntityType).min(1).default(["organisation", "department", "project", "customer",
"object", "person", "case", "topic"]), // unique; ^[a-z][a-z0-9_]{1,29}$
max_depth: z.number().int().min(2).max(64).default(16),
proposed_min_confidence: z.number().min(0).max(1).nullable().default(null), // null: only confirmed proposed links count
}).strict().prefault({}),
approval: ApprovalConfig.optional(), // approval-modes.md §3; absent: every scope `manual`
}).strict();
The service never schedules anything: an OS timer calls groundnote backup nightly (cli.md §8),
which asks the service over the ops socket to copy (copy only, 11.1) and to probe (11.2).
Litestream supervision stays the operator's (section 12). backup tells backup run, backup probe, backup verify and restore where the newest backup is: copy →
<directory>/scopes/<scope>.sqlite and <directory>/ops.sqlite (the data dir layout);
litestream → litestream restore -o <tmp> <live path>, which finds the replica in Litestream's
own config (LITESTREAM_CONFIG or /etc/litestream.yml); target and prefix are not read by
the code. directory and export_directory must already exist (a missing mount must not turn
into a local directory) and must not lie inside the data dir. export_directory set → the probe
writes the nightly Markdown export there (11.2), plaintext until slice R5; unset → no nightly
export. deadman_url is read by the CLI only (backup nightly, D95).
at_rest is the operator's statement that the data dir lies on an encrypted volume, for hosts
where doctor cannot detect it (container, network file system); attested_at is the day the
operator last checked, older than 365 days is a fail (cli.md §6). doctor holds the two age
limits of groundnote doctor (26 h: a daily timer with jitter and DST may slip by up to two
hours). The calendar-day schema of attested_at lives in config/ (domain imports config,
not the reverse).
search.semantic and search.reranker leave Off with the search-stages slice (15.4), which
owns their schema (snake_case, see cli.md §6 "Stage checks"); this slice does not change them.
search.lexical (src/config/lexical.ts) configures the lexical stage a (src/search/lexical/,
measures in search/lexical.md); a missing key takes the module default:
const LexicalConfig = z.object({
features: z.object({ // default on: stem, decompound, normalize, fuzzy,
stem, decompound, normalize, trigram, fuzzy, // synonyms, validity, keyterms; off: trigram, prf
synonyms, validity, keyterms, prf, // each z.boolean(), all optional
}).partial().strict().prefault({}),
field_weights: z.tuple([w, w, w, w]).optional(), // bm25 keywords/statement/relations/quote, default 4/3/2/1
fusion: z.enum(["score", "rrf"]).optional(), // default "score"
rrf_k: z.number().int().min(1).max(1000).optional(), // default 60
sub_weights: z.object({ word, stem, tri, fuzzy, syn, prf }).partial().strict().prefault({}),
max_key_terms: z.number().int().min(1).max(12).optional(), // default 6
long_text_tokens: z.number().int().min(1).max(10_000).optional(), // default 25
max_fuzzy_terms: z.number().int().min(0).max(12).optional(), // default 8
}).strict(); // w = z.number().min(0).max(10)
search.synonyms and search.languages feed the lexical stage too. Pool size (200) and the term
cap (12) are contract values, not keys. Changing stem, decompound, normalize or trigram
changes what is stored: the next search or write rebuilds the lex_* tables of the scope (4.1).
search.min_coverage is the only relevance threshold (7.6a). The tree limits and resolve
thresholds of the graph are code constants (graph.md §7).
limits are token buckets in process memory (src/http/rate-limit.ts, clock from the app deps):
each key holds n tokens and refills n per minute (exports_per_hour: per hour) continuously;
they start fresh on restart. Requests, decisions and exports are counted per grant after
authentication, so a request without a valid credential never counts. Decisions default to 60 a
minute: confirming one hierarchy took 16 structure reviews in G9, which 10 a minute blocked; a bulk
confirm is open (15.5). An export is the whole scope, so it has its own budget of 6 an hour. Login attempts are counted per socket peer address (@hono/node-server);
X-Forwarded-For is read only with trusted_proxy: true, and then only its last entry. Over the
limit → 429 rate-limited with Retry-After in whole seconds (6.6).
| Env | Default | Rule |
|---|---|---|
GROUNDNOTE_AUTH_MODE |
none | cloudflare-access or dev-token; missing → refuse start |
GROUNDNOTE_CONFIG / GROUNDNOTE_DATA_DIR |
./groundnote.json / ./data |
data dir must be writable and private: created 0700 under umask 077, so SQLite files are 0600; holds the ops socket ops.sock |
GROUNDNOTE_HOST / GROUNDNOTE_PORT |
127.0.0.1 / 8794 |
public listener |
GROUNDNOTE_OPS_TOKEN |
— | required, ≥ 32 chars |
GROUNDNOTE_SESSION_SECRET |
— | required, ≥ 32 chars (CSRF, dev session) |
GROUNDNOTE_DEV_MCP_TOKEN, GROUNDNOTE_DEV_APPROVAL_TOKEN |
— | dev-token mode only; ≥ 32 chars, different |
GROUNDNOTE_DEV_IDENTITY |
— | dev-token mode only; email of the dev person |
GROUNDNOTE_DEV_ALLOW_NON_LOOPBACK |
false |
dev-token mode: allow non-loopback HOST (container; publish only -p 127.0.0.1:…) |
GROUNDNOTE_URL, GROUNDNOTE_API_TOKEN, GROUNDNOTE_API_ACCESS_JWT, GROUNDNOTE_ACCESS_JWT |
— | CLI only, never read by the service: api url, service bearer, person JWT for aud_api, person JWT for aud_approval (export); or cli.json (cli.md §3) |
Refuse start when: mode missing/unknown; any required secret missing/short; any two secrets equal;
cloudflare-access mode without access, with a non-https public_url, or with any
GROUNDNOTE_DEV_* set; dev-token mode with a non-loopback host (127.0.0.1, ::1, localhost)
unless the allow flag is true; GROUNDNOTE_OPS_PORT set (removed, the ops listener is a socket);
the data dir, scopes/ or a *.sqlite, *.sqlite-wal, *.sqlite-shm or *.sqlite-journal file
in them group- or world-accessible (data-dir-permissions, the message names the path and
chmod 700/chmod 600); ops.sock exists as a non-socket or is served by another process
(ops-socket-unavailable; a stale socket from a crash is removed); the ops.sock path, as given
and resolved, longer than sun_path (104 bytes on macOS and the BSDs, 108 on Linux, null byte
included; ops-socket-path-too-long, the message names length, limit and "choose a shorter
GROUNDNOTE_DATA_DIR"). Logs are JSON lines to stdout, UTC, never containing tokens, JWTs,
cookies, request bodies or source text.
4. Storage#
data/scopes/<scope>.sqlite per scope, data/ops.sqlite per instance. Each connection:
journal_mode=WAL, busy_timeout=5000, synchronous=NORMAL, foreign_keys=ON,
recursive_triggers=ON (otherwise INSERT OR REPLACE deletes a row without firing the append-only
DELETE trigger). No manual
checkpoints, no VACUUM (Litestream-friendly, see backup.md). The service is the
only writer of every file. Migrations are numbered TS modules, applied in one transaction each;
meta.schema_version records the last one. Unknown higher schema_version → refuse to open.
Files in data/scopes/ whose name is not a configured scope are ignored with a warning.
4.1 Scope file (migrations/scope/0001-init.ts)#
CREATE TABLE meta (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
scope TEXT NOT NULL, schema_version INTEGER NOT NULL CHECK (schema_version >= 1),
created_at TEXT NOT NULL);
CREATE TRIGGER meta_fixed BEFORE UPDATE OF scope, created_at ON meta BEGIN SELECT RAISE(ABORT, 'append-only'); END;
CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'append-only'); END;
CREATE TABLE rule (
seq INTEGER PRIMARY KEY, -- explicit max+1; = rowid of rule_fts / rejected_fts
id TEXT NOT NULL UNIQUE, -- <scope>-<nnnn>, never reused
created_at TEXT NOT NULL, created_by TEXT NOT NULL);
CREATE TRIGGER rule_id_in_scope BEFORE INSERT ON rule
WHEN substr(NEW.id, 1, length((SELECT scope FROM meta)) + 1) <> (SELECT scope FROM meta) || '-'
OR length(NEW.id) - length((SELECT scope FROM meta)) - 1 < 4
OR substr(NEW.id, length((SELECT scope FROM meta)) + 2) GLOB '*[^0-9]*'
BEGIN SELECT RAISE(ABORT, 'rule-id-not-in-scope'); END;
CREATE TABLE source (
id TEXT PRIMARY KEY, -- jamie:<id> | mail:<message-id> | manual:<grant-id>
kind TEXT NOT NULL CHECK (kind IN ('jamie','mail','manual')),
title TEXT, occurred_at TEXT, sender TEXT,
thread TEXT, -- mail only: mail:<root message-id>
link TEXT,
excerpt TEXT, -- mail: text as received; jamie: NULL (segments); manual: NULL
fulltext_sha256 TEXT,
fetched_at TEXT NOT NULL,
feeder TEXT NOT NULL, -- Phase 1: grant id that stored it
CHECK (substr(id, 1, length(kind) + 1) = kind || ':'),
CHECK (kind = 'mail' OR thread IS NULL),
CHECK (kind <> 'mail' OR excerpt IS NOT NULL),
CHECK (kind = 'mail' OR excerpt IS NULL),
CHECK ((kind = 'manual') = (fulltext_sha256 IS NULL)),
CHECK (kind = 'manual' OR (title IS NOT NULL AND occurred_at GLOB '????-??-??T??:??:??*Z')));
CREATE INDEX source_thread ON source(thread) WHERE thread IS NOT NULL;
CREATE TABLE source_segment ( -- jamie transcript segments as received
source_id TEXT NOT NULL REFERENCES source(id), n INTEGER NOT NULL CHECK (n >= 1),
start_s INTEGER NOT NULL CHECK (start_s >= 0), speaker TEXT,
text TEXT NOT NULL CHECK (length(text) > 0),
PRIMARY KEY (source_id, n)) WITHOUT ROWID;
CREATE TABLE source_attachment (
source_id TEXT NOT NULL REFERENCES source(id), n INTEGER NOT NULL CHECK (n >= 1),
filename TEXT NOT NULL, media_type TEXT NOT NULL,
size INTEGER CHECK (size IS NULL OR size >= 0),
external TEXT, -- paperless:123, provider attachment id
text TEXT,
text_origin TEXT NOT NULL CHECK (text_origin IN ('ocr','description','none')),
CHECK ((text_origin = 'none') = (text IS NULL)),
PRIMARY KEY (source_id, n)) WITHOUT ROWID;
CREATE TRIGGER attachment_contiguous BEFORE INSERT ON source_attachment
WHEN NEW.n <> 1 + coalesce((SELECT max(n) FROM source_attachment WHERE source_id = NEW.source_id), 0)
BEGIN SELECT RAISE(ABORT, 'attachment-gap'); END;
CREATE TABLE rule_version (
rule_id TEXT NOT NULL REFERENCES rule(id),
version INTEGER NOT NULL CHECK (version >= 1),
kind TEXT NOT NULL CHECK (kind IN ('rule','note')),
statement TEXT NOT NULL CHECK (length(statement) BETWEEN 10 AND 500),
source_id TEXT NOT NULL REFERENCES source(id),
quote TEXT NOT NULL,
timestamp_s INTEGER CHECK (timestamp_s IS NULL OR timestamp_s >= 0),
source_link TEXT,
said_at TEXT NOT NULL CHECK (said_at GLOB '????-??-??T??:??:??*Z'),
speaker TEXT,
relations TEXT NOT NULL CHECK (json_valid(relations) AND json_type(relations) = 'array'),
keywords TEXT NOT NULL CHECK (json_valid(keywords) AND json_type(keywords) = 'array'
AND json_array_length(keywords) <= 30),
valid_from TEXT NOT NULL CHECK (valid_from GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'),
valid_until TEXT CHECK (valid_until IS NULL OR valid_until GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'),
supersedes TEXT REFERENCES rule(id),
superseded_by TEXT REFERENCES rule(id),
status TEXT NOT NULL CHECK (status IN ('proposed','confirmed','superseded','rejected')),
reason TEXT NOT NULL CHECK (length(trim(reason)) > 0),
changed_by TEXT NOT NULL, -- grant id
changed_at TEXT NOT NULL CHECK (changed_at GLOB '????-??-??T??:??:??*Z'),
PRIMARY KEY (rule_id, version),
CHECK ((status = 'superseded') = (valid_until IS NOT NULL)),
CHECK (superseded_by IS NULL OR status = 'superseded'),
CHECK (valid_until IS NULL OR valid_until >= valid_from),
CHECK (supersedes IS NULL OR supersedes <> rule_id),
CHECK (superseded_by IS NULL OR superseded_by <> rule_id),
CHECK ((source_id GLOB 'jamie:*') = (timestamp_s IS NOT NULL)),
CHECK (length(quote) >= CASE WHEN source_id GLOB 'manual:*' THEN 1 ELSE 20 END));
CREATE TRIGGER rule_version_transition BEFORE INSERT ON rule_version BEGIN
SELECT RAISE(ABORT, 'version-gap') WHERE NEW.version <>
1 + coalesce((SELECT max(version) FROM rule_version WHERE rule_id = NEW.rule_id), 0);
SELECT RAISE(ABORT, 'terminal-state') WHERE (SELECT status FROM rule_version
WHERE rule_id = NEW.rule_id ORDER BY version DESC LIMIT 1) IN ('rejected','superseded');
SELECT RAISE(ABORT, 'invalid-transition') WHERE
(NEW.version = 1 AND NEW.status <> 'proposed')
OR (NEW.version > 1 AND NEW.status = 'proposed')
OR (NEW.status = 'rejected' AND (SELECT status FROM rule_version WHERE rule_id = NEW.rule_id
ORDER BY version DESC LIMIT 1) <> 'proposed')
OR (NEW.status = 'superseded' AND (SELECT status FROM rule_version WHERE rule_id = NEW.rule_id
ORDER BY version DESC LIMIT 1) <> 'confirmed');
SELECT RAISE(ABORT, 'source-immutable') WHERE NEW.version > 1 AND EXISTS (
SELECT 1 FROM rule_version f WHERE f.rule_id = NEW.rule_id AND f.version = 1 AND (
f.source_id IS NOT NEW.source_id OR f.quote IS NOT NEW.quote
OR f.timestamp_s IS NOT NEW.timestamp_s OR f.source_link IS NOT NEW.source_link));
END;
CREATE VIEW current_version AS SELECT v.* FROM rule_version v
WHERE v.version = (SELECT max(w.version) FROM rule_version w WHERE w.rule_id = v.rule_id);
CREATE TABLE decision (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_id TEXT NOT NULL, version INTEGER NOT NULL, -- the version the person saw
kind TEXT NOT NULL CHECK (kind IN ('confirm','reject','revoke','review')),
decided_by TEXT NOT NULL CHECK (decided_by GLOB 'person:*'), -- grant id
decided_by_identity TEXT NOT NULL, -- email from the verified credential
decided_at TEXT NOT NULL CHECK (decided_at GLOB '????-??-??T??:??:??*Z'),
reason TEXT,
new_version INTEGER, -- NULL only for review without change
FOREIGN KEY (rule_id, version) REFERENCES rule_version(rule_id, version),
FOREIGN KEY (rule_id, new_version) REFERENCES rule_version(rule_id, version),
CHECK (kind NOT IN ('reject','revoke') OR length(trim(coalesce(reason, ''))) > 0),
CHECK (kind = 'review' OR new_version IS NOT NULL),
CHECK (new_version IS NULL OR new_version > version));
-- append-only: one pair per table (rule, rule_version, decision, source, source_attachment, source_segment)
CREATE TRIGGER rule_no_update BEFORE UPDATE ON rule BEGIN SELECT RAISE(ABORT, 'append-only'); END;
CREATE TRIGGER rule_no_delete BEFORE DELETE ON rule BEGIN SELECT RAISE(ABORT, 'append-only'); END;
-- … same two triggers for the other five tables …
CREATE VIRTUAL TABLE rule_fts USING fts5(keywords, statement, relations, quote,
tokenize = 'unicode61 remove_diacritics 2'); -- rowid = rule.seq; never contains rejected rules
CREATE VIRTUAL TABLE rejected_fts USING fts5(keywords, statement, relations, quote,
tokenize = 'unicode61 remove_diacritics 2'); -- approval only: "previously rejected similar"
CREATE TABLE search_log ( -- every scoped access, not only searches
id TEXT PRIMARY KEY, -- search_id (UUID) shared by all scopes of one request
at TEXT NOT NULL, grant_id TEXT NOT NULL,
channel TEXT NOT NULL CHECK (channel IN ('mcp','api','approval','proactive','task')),
operation TEXT NOT NULL CHECK (operation IN ('search','read_rule','list_rules','provenance',
'propose','list_proposals','list_rejected','decide','store_source','feedback','list_scopes','status')),
outcome INTEGER NOT NULL, -- HTTP-equivalent status
cross_scope INTEGER NOT NULL CHECK (cross_scope IN (0,1)),
refs TEXT NOT NULL CHECK (json_valid(refs)), -- heads / touched refs with version
terms TEXT, variants TEXT, stages TEXT, -- JSON, search only
input_sha256 TEXT, -- of the request text; the text is never stored
CHECK (operation <> 'search' OR (terms IS NOT NULL AND variants IS NOT NULL AND stages IS NOT NULL)));
CREATE TABLE feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
search_id TEXT NOT NULL REFERENCES search_log(id),
ref TEXT NOT NULL, -- rule/<id>@<n> for a verdict, rule/<id> for missing
verdict TEXT NOT NULL CHECK (verdict IN ('relevant','irrelevant','missing')),
given_by TEXT NOT NULL, given_at TEXT NOT NULL);
FTS maintenance (db/fts.ts, no triggers): after every write to a rule, in the same
transaction, DELETE FROM rule_fts WHERE rowid = seq, then — unless the current status is
rejected — insert the current version: keywords joined by space, statement, relations as
"<kind> <name>" joined by space, quote. On reject, decision/ inserts the same row into
rejected_fts instead.
Approval tables (migrations/scope/0004-approval.ts, approval-modes.md §7): classification,
source_auth, secret_finding; decision is rebuilt with origin (grant·auto), mode,
classification_id and kind retract; search_log/feedback are rebuilt for the operations
retract, list_exceptions, list_auto.
Search log operations. Migration 0002-graph rebuilds search_log and feedback (SQLite cannot
alter a CHECK) and extends operation with resolve_entity, read_entity, create_entity,
move_entity, update_entity, propose_link, list_links, list_structure, review_structure,
archive_entity and export. The graph tables entity, entity_version, entity_closure,
entity_fts, link and review are in graph.md §3.
Lexical tables (migrations/scope/0003-lexical.ts, the same SQL as
search/lexical/schema.ts; rowid = rule.seq): lex_word (FTS5, the four columns, words and
canonical code tokens), lex_stem (FTS5, Snowball stems de+en and compound parts), lex_tri
(FTS5 trigram, only filled with features.trigram), lex_word_vocab / lex_stem_vocab
(fts5vocab over the first two, for IDF, key terms and typo correction), lex_doc (status,
valid_from, valid_until of the indexed version) and lex_meta (build = analyzer version and
the content-relevant features). Derived data only. reindexRule(db, ruleId, lexical) maintains
them next to rule_fts in the same transaction (proposal create; confirm incl. the superseded
predecessor; reject; revoke; review with changed keywords); a rejected rule is removed and never
written. When lex_meta.build is missing (a file migrated to 0003 with existing rules) or differs
from the configured options, the first search or write on the connection rebuilds the tables from
current_version (not rejected), in one transaction.
4.2 Ops file (migrations/ops/0001-init.ts)#
CREATE TABLE meta (singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
schema_version INTEGER NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE grant (
seq INTEGER PRIMARY KEY,
id TEXT NOT NULL UNIQUE, -- <kind>:<seq>, e.g. person:2, service:3
kind TEXT NOT NULL CHECK (kind IN ('person','service')),
auth TEXT NOT NULL CHECK (auth IN ('email','access-service','bearer')),
identity TEXT NOT NULL, -- lowercased email | Access common_name | sha256 hex of bearer
scopes TEXT NOT NULL CHECK (json_valid(scopes) AND json_array_length(scopes) >= 1),
rights TEXT NOT NULL CHECK (json_valid(rights) AND json_array_length(rights) >= 1),
label TEXT, active INTEGER NOT NULL CHECK (active IN (0,1)),
created_at TEXT NOT NULL, disabled_at TEXT,
CHECK ((kind = 'person') = (auth = 'email')),
CHECK (kind = 'person' OR instr(rights, '"decide"') = 0), -- rights stored as canonical JSON array
CHECK ((active = 1) = (disabled_at IS NULL)));
CREATE UNIQUE INDEX grant_active_identity ON grant(auth, identity) WHERE active = 1;
CREATE TRIGGER grant_disable_only BEFORE UPDATE ON grant WHEN NOT (OLD.active = 1 AND NEW.active = 0
AND NEW.disabled_at IS NOT NULL AND NEW.id IS OLD.id AND NEW.kind IS OLD.kind AND NEW.auth IS OLD.auth
AND NEW.identity IS OLD.identity AND NEW.scopes IS OLD.scopes AND NEW.rights IS OLD.rights
AND NEW.label IS OLD.label AND NEW.created_at IS OLD.created_at)
BEGIN SELECT RAISE(ABORT, 'grant-disable-only'); END;
CREATE TRIGGER grant_no_delete BEFORE DELETE ON grant BEGIN SELECT RAISE(ABORT, 'append-only'); END;
CREATE TABLE backup_state ( -- later phases add backup/export/probe columns
scope TEXT PRIMARY KEY, created_at TEXT NOT NULL,
version_count INTEGER NOT NULL CHECK (version_count >= 0), counted_at TEXT NOT NULL);
CREATE TRIGGER backup_state_monotonic BEFORE UPDATE OF version_count ON backup_state
WHEN NEW.version_count < OLD.version_count BEGIN SELECT RAISE(ABORT, 'count-decreased'); END;
CREATE TRIGGER backup_state_no_delete BEFORE DELETE ON backup_state BEGIN SELECT RAISE(ABORT, 'append-only'); END;
-- 0002-backup-verify: result of the last `groundnote backup verify`
ALTER TABLE backup_state ADD COLUMN verified_at TEXT; -- UTC instant
ALTER TABLE backup_state ADD COLUMN verify_ok INTEGER; -- 0/1
ALTER TABLE backup_state ADD COLUMN verify_result TEXT; -- JSON report (checks, counts, exceptions; no content)
ALTER TABLE backup_state ADD COLUMN verified_counts TEXT; -- JSON append-only row counts of the last passed backup
-- 0003-backup-run: backup run and restore probe (section 11.1, 11.2); writer: src/db/ops-store.ts
ALTER TABLE backup_state ADD COLUMN last_backup_at TEXT; -- UTC instant of the last successful copy of this scope (copy only)
ALTER TABLE backup_state ADD COLUMN probed_at TEXT; -- UTC instant of the last probe of this scope
ALTER TABLE backup_state ADD COLUMN probe_ok INTEGER; -- 0/1
ALTER TABLE backup_state ADD COLUMN probe_result TEXT; -- JSON report (checks, counts, exceptions; no content)
ALTER TABLE backup_state ADD COLUMN exported_at TEXT; -- UTC instant of the last nightly export file
ALTER TABLE backup_state ADD COLUMN export_sha256 TEXT; -- 64 lowercase hex of that archive
CREATE TABLE backup_run ( -- one row per `backup run` and per `backup probe`
seq INTEGER PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('run','probe')),
backup_kind TEXT NOT NULL CHECK (backup_kind IN ('copy','litestream')),
started_at TEXT NOT NULL, finished_at TEXT NOT NULL, -- UTC instants, finished_at >= started_at
ok INTEGER NOT NULL CHECK (ok IN (0,1)),
result TEXT NOT NULL CHECK (json_valid(result))); -- the route's answer; paths, counts, hashes, no content
CREATE TRIGGER backup_run_no_update BEFORE UPDATE ON backup_run BEGIN SELECT RAISE(ABORT, 'append-only'); END;
CREATE TRIGGER backup_run_no_delete BEFORE DELETE ON backup_run BEGIN SELECT RAISE(ABORT, 'append-only'); END;
Every new instant column has the GLOB '????-??-??T??:??:??*Z' CHECK of 0002, probe_ok IN (0,1), probe_result json_valid, export_sha256 length = 64 AND NOT GLOB '*[^0-9a-f]*'.
verified_counts only moves on a passed verification or probe; it is the floor for the next
one. The time of the last copy of ops.sqlite itself is finished_at of the newest backup_run
row with kind = 'run' whose result.ops.ok is true (ops_backup_at in GET /ops/status).
4.3 Ids, refs, locks#
- Rule id
<scope>-<nnnn>:^[a-z][a-z0-9-]{1,39}-\d{4,}$; number =rule.seq, zero-padded to 4. Scope of an id = id without the trailing-\d{4,}. Refs:rule/<id>,rule/<id>@<n>,source/<source-id>,attachment/<source-id>#<n>. Hits and bundles always carry@<n>. - Source ids:
jamie:+[A-Za-z0-9._-]{1,200};mail:<message-id>incl. angle brackets (^mail:<[^<>\s]+>$), thread is the root message id in the same form;manual:<grant-id>, set by the service from the caller's grant (one source row per grant, created on first use). - Open check (scope-store, on first use and cached): file exists,
PRAGMA quick_checkisok,meta.scopeequals the file name,schema_versionknown, andcount(rule_version)≥backup_state.version_count. Any failure → scope locked until restart,503 scope-locked, error log line. After every committed write the service raisesbackup_state.version_count.
4.4 Markdown export (src/export/)#
buildExport(db, {zone, now}) reads one scope file inside one read transaction (one
snapshot) and returns the files below; exportArchive packs them as <scope>/<file> into a
POSIX ustar .tar.gz built from node built-ins (zlib), so no archive dependency is audited or
updated. GET /v1/export/:scope and groundnote backup verify use the same function. The export
is never read back by the service.
| File | Content |
|---|---|
INDEX.md |
Section "Rules" (id, kind, status, statement cut at 80 characters, last change) and its own section "Rejected" |
<id>.md |
Frontmatter of the current version as JSON-valued YAML (UTC instants): id, version, kind, status, valid_from, valid_until, supersedes, superseded_by, said_at, speaker, relations, keywords, source {kind, id, timestamp, link}, confirmed_by, confirmed_at, last_reviewed, changed_by, changed_at; body ## Statement, ## Quote (foreign text as block quotes, so it cannot open a heading), ## History (every version with status, reason and grant; every decision with grant and email), times in config.zone with zone abbreviation (2026-03-14 17:02 CET) |
history.jsonl |
Every row of every allowlisted table (EXPORTED_TABLES: meta, rule, source, source_segment, source_attachment, rule_version, decision, entity, entity_version, link, review), one {"table", "row"} per line, tables in creation order, rows by rowid (WITHOUT ROWID: by primary key). Deliberately left out (NOT_EXPORTED_TABLES): search_log, feedback, and the derived entity_closure, lex_meta, lex_doc; FTS tables are never listed. A test fails when a migration adds a table that is in neither list |
manifest.json |
format: "groundnote-export/1", scope, exported_at, zone, schema_version, row count per exported table, {name, bytes, sha256} per file |
The export contains rejected rules and the e-mail of every deciding person: it is the complete
artifact of the scope. It is therefore served only on the approval channel to persons with
decide on the scope, who see all of it there anyway; services get 403 channel-not-allowed.
Machine backups use the ops socket (backup verify) and the backup copies (D71). Foreign text is
normalized (CRLF, CR, NEL, U+2028, U+2029 → LF) and its Markdown/HTML characters
(\ ` | [ ] < >) are backslash-escaped, so a statement cannot form a link, tag, heading or cell.
5. Access#
5.1 Channels and identities#
| Channel | Paths | cloudflare-access mode | dev-token mode | Kinds | Max rights |
|---|---|---|---|---|---|
mcp |
/mcp |
Cf-Access-Jwt-Assertion, aud aud_mcp |
Authorization: Bearer $GROUNDNOTE_DEV_MCP_TOKEN → GROUNDNOTE_DEV_IDENTITY |
person | read, propose |
approval |
/approval/*, POST /v1/rules/:id/decision, POST /v1/reviews, POST /v1/entities/:id/archive |
JWT, aud aud_approval |
session cookie from /approval/login |
person | read, decide |
api |
other /v1/* |
JWT, aud aud_api (person or Access service token) or service bearer |
service bearer | person, service | read, propose |
JWT check (jose): createRemoteJWKSet(<team_domain>/cdn-cgi/access/certs), jwtVerify with
algorithms: ['RS256'], issuer: team_domain, audience: <aud of this channel>,
clockTolerance: 30, exp required, nbf honoured, an iat more than 30 s in the future → 401.
The key set has explicit fetch limits: cooldownDuration 60 s (no refetch for an unknown kid
sooner), cacheMaxAge 10 min, timeoutDuration 5 s. Identity: string email → person
(printable ASCII only, else 401; lowercased, auth email); no email but common_name → service (auth access-service); else 401.
Service bearer: Authorization: Bearer gn_<43 base64url chars>, looked up by SHA-256 hex (auth
bearer). Both a JWT header and a bearer on one request → 401. Dev tokens are compared with
timingSafeEqual on SHA-256 digests.
5.2 Scope-check pipeline (every call, every channel; auth/scope-check.ts)#
- Identity → active grant (
grantrow with matchingauth+identity,active = 1). Else 401unauthorized; on/mcpand/v1/*with headerWWW-Authenticate: Bearer resource_metadata="<public_url>/.well-known/oauth-protected-resource". Wrong identity kind for the channel (service onmcp/approval) → 403 channel-not-allowed. - Parse the body/query (Zod; 400 validation-failed). Every requested scope — from parameters
and from the id of every ref — must be in
grant.scopes. Else 403 scope-not-allowed, identical whether the scope exists or not. - More than one scope only with
cross_scope: true, else 400 multiple-scopes-without-flag. No scope given → 400validation-failed; there is no default scope. Exception: the grant-wide listingsscopes(MCP),GET /approvalandGET /v1/statusname no scope and cover exactly the grant's scopes. - The operation's right (read: search/read/list/provenance/status/feedback; propose:
propose/store_source; decide: decide) must be in the channel's max rights and in
grant.rights. Else 403 channel-not-allowed. - Open every touched scope (4.3; 503 scope-locked). Execute.
- Log one
search_logrow in every touched scope (sameidfor all), including outcomes 404/409/422. Denials in steps 1–4 go to the process log only (grant id, channel, code).
5.3 Approval session and CSRF#
- Dev-token mode:
GET /approval/login(form),POST /approval/login(tokencompared toGROUNDNOTE_DEV_APPROVAL_TOKEN; at mostlimits.login_attempts_per_minuteper client address) → cookie__Host-gn_session(public_urlhttps:Secure, noDomain) orgn_session(http loopback dev only, noSecure) = base64url({sub: identity, exp}) +.+ HMAC-SHA256(GROUNDNOTE_SESSION_SECRET),HttpOnly; SameSite=Strict; Path=/, lifetime 8 h;POST /approval/logout. In cloudflare-access mode these routes are 404. - Every state-changing approval request (decision, logout) needs both: same origin (
Originequals the origin ofpublic_url; withoutOrigin,Sec-Fetch-Site: same-origin; neither → fail) and form field / headercsrfequal to base64url(HMAC-SHA256(secret,"csrf\0" + identity)), compared in constant time. Else 403 csrf-failed. - Pages send
Content-Security-Policy: default-src 'none'; style-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none',X-Content-Type-Options: nosniff. All output is escaped (hono/html).
6. HTTP API#
| Method | Path | Channel | Right | Body / query → response |
|---|---|---|---|---|
| GET | /health |
none | — | {ok: true, service: "groundnote", instance, version} |
| GET | /.well-known/oauth-protected-resource |
none | — | RFC 9728: {resource: "<public_url>/mcp", authorization_servers: [team_domain], bearer_methods_supported: ["header"]}; dev mode without authorization_servers |
| POST | /mcp |
mcp | read/propose | Streamable HTTP, stateless; GET/DELETE /mcp → 405 |
| POST | /v1/sources |
api | propose | SourceRequest → 201 {created: true, id} / 200 {created: false, id} |
| GET | /v1/sources/:id?scope=&include= |
api | propose | own sources only (6.2a) → 200 SourceOut; another grant's or an unknown id → the same 404 |
| POST | /v1/proposals |
api | propose | ProposalRequest → 201 ProposalCreated |
| GET | /v1/proposals?scope= |
api | read | → {scope, proposals: ProposalSummary[]} oldest first |
| POST | /v1/rules/:id/retract |
api | propose | {version, reason} → 200 {ref, status: "superseded", valid_until}; only auto-adopted rules (approval-modes.md §8) |
| POST | /v1/rules/:id/decision |
approval | decide | DecisionRequest (JSON or form) → 200 {ref, status} / form: 303 to /approval/rules/:id |
| POST | /v1/search |
api | read | SearchRequest → 200 SearchResponse |
| POST | /v1/search/:search_id/feedback |
api | read | FeedbackRequest → 201 {stored} |
| GET | /v1/rules?scope=&status=&relation=&limit=&after= |
api | read | → {scope, rules: RuleVersionOut[], next: string | null} |
| GET | /v1/rules/:id?version= |
api | read | → {bundle, versions: RuleVersionOut[], decisions: DecisionOut[]} |
| GET | /v1/provenance/:id?version= |
api | read | → Provenance |
| GET | /v1/status |
api | read | → {instance, version, scopes: [{scope, counts: {proposed, confirmed, superseded, rejected} | null, locked}], stages: {a: "on", b: "on" | "off" | "loading" | "failed", c: "on" | "off" | "budget"}, checks: [{id: "backup-age" | "restore-probe", status: "ok" | "warn" | "fail" | "skip"}]}; stages is what a search over the grant's scopes meets now: b off while disabled or no scope allows local, loading/failed by its model, c off while disabled or no scope may run it, budget once the monthly cap is reached (states only, no amount); a locked scope has counts: null, locked: true and does not turn the answer into 503; checks (D99) is the worst status over the grant's created scopes only (ops.sqlite is the ops side's and is checked by doctor), computed with doctor's pure functions, without message, remedy or path |
| GET | /v1/export/:scope |
approval | decide | persons only (D71) → 200 application/gzip (section 4.4), content-disposition: attachment; filename="<scope>-export-<UTC stamp>.tar.gz"; process log line export with grant, scope, counts |
Body limits: 10 MiB on /v1/sources, 256 KiB elsewhere (413). relation query = <kind>:<name>;
status ∈ proposed|confirmed|superseded (rejected is not addressable); limit 1–200 (default
100), after = rule id (order by seq).
6.1 Shared schemas (domain/schemas.ts)#
const Day = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isCalendarDay);
const Instant = z.iso.datetime(); // UTC, ends with Z, no offset
const RuleId = z.string().regex(/^[a-z][a-z0-9-]{1,39}-\d{4,}$/);
const RuleRef = z.string().regex(/^rule\/[a-z][a-z0-9-]{1,39}-\d{4,}(@[1-9]\d*)?$/);
const Relation = z.object({ kind: z.enum(["customer", "product", "supplier"]),
name: z.string().min(1).max(200), ref: z.string().min(1).max(200).nullable() }).strict();
const Keywords = z.array(z.string().min(2).max(100)).max(30); // including synonyms
const Status = z.enum(["proposed", "confirmed", "superseded", "rejected"]);
const ErrorBody = z.object({ error: z.string(), message: z.string().optional(),
current: RuleRef.optional(), issues: z.array(z.unknown()).optional(),
remedy: z.string().optional() });
6.2 Sources#
const AttachmentIn = z.object({
filename: z.string().min(1).max(500), media_type: z.string().min(3).max(200),
size: z.number().int().nonnegative().nullable(), external: z.string().max(500).nullable(),
text: z.string().max(2_000_000).nullable(),
text_origin: z.enum(["ocr", "description"]).nullable(),
}).strict().refine(a => (a.text === null) === (a.text_origin === null)); // stored as 'none' when null
const Segment = z.object({ start_s: z.number().int().nonnegative(),
speaker: z.string().min(1).max(200).nullable(), text: z.string().min(1).max(10_000) }).strict();
const SourceRequest = z.object({
scope: ScopeName,
source: z.discriminatedUnion("kind", [
z.object({ kind: z.literal("mail"), id: MailId, title: z.string().min(1).max(500),
occurred_at: Instant, sender: z.string().max(500).nullable(),
thread: MailId.nullable(), link: Link.nullable() }).strict(),
z.object({ kind: z.literal("jamie"), id: JamieId, title: z.string().min(1).max(500),
occurred_at: Instant, sender: z.string().max(500).nullable(),
link: Link }).strict(),
]),
text: z.string().min(1).max(5_000_000).optional(), // mail: required
segments: z.array(Segment).min(1).max(20_000).optional(), // jamie: required, start_s non-decreasing
attachments: z.array(AttachmentIn).max(200), // complete, incl. unreadable ones
}).strict(); // superRefine: mail ⇔ text present and no segments; jamie ⇔ segments present and no text
const SourceStored = z.object({ created: z.boolean(), id: z.string() }); // id = source id (D66)
Stored synchronously. First call → 201 (source, segments n = array position, attachments,
fulltext_sha256 = SHA-256 of text or of the canonical JSON of segments). A mail source may
carry auth {from, authentication_results} (approval-modes.md §4), stored in source_auth and part
of the identity check below. Same source.id in
the same scope again, from the same grant and identical (source fields, full-text hash, the complete
attachment list by position) → 200, created: false; nothing is written. Attachments are
never added later (D66).
6.2a Reading a stored source back (GET /v1/sources/:id, D92)#
The feeder's read-back of its own write (groundnote sources get), not a knowledge read: persons
and agents read sources through provenance. Same checks as POST: scope (query, required)
passes steps 1–4 of 5.2 with the right propose; then the row must exist in that scope and
source.feeder must equal the caller's grant id. An unknown id, an id stored by another grant
and an id of another scope all answer the same 404 not-found with the same body, so the route
tells nobody whether a source exists or which feeder stored it (threat model R3, D66). The :id
is the URL-encoded source id (mail:<…> contains <, >, @).
const SourceQuery = z.object({ scope: ScopeName,
include: z.string().optional() }).strict(); // comma list of "text" | "attachments", each at most once
const SourceOut = z.object({
id: z.string(), scope: ScopeName, kind: z.enum(["mail", "jamie", "manual"]),
title: z.string().nullable(), occurred_at: Instant.nullable(), sender: z.string().nullable(),
thread: z.string().nullable(), link: z.string().nullable(),
fulltext_sha256: z.string().nullable(), fetched_at: Instant,
text_chars: z.number().int().nullable(), // mail: length of the stored text, else null
segment_count: z.number().int().nullable(), // jamie, else null
attachments: z.array(z.object({ ref: z.string(), n: z.number().int(), filename: z.string(),
media_type: z.string(), size: z.number().int().nullable(), external: z.string().nullable(),
text_origin: z.enum(["ocr", "description", "none"]),
text: z.string().nullable().optional() }).strict()), // only with include=attachments
text: z.string().optional(), // include=text, mail
segments: z.array(Segment).optional(), // include=text, jamie (start_s, speaker, text)
}).strict(); // no `feeder`, no `excerpt` beyond `text`
Default answer: metadata, hash and the attachment list, no content; content only on request
(include). The access goes to the process log (source-read: grant, scope, outcome), not to
search_log, whose operation CHECK has no read_source yet (as for export, D71); the next
scope migration adds both. A repeat from another grant, with different source fields or a different
attachment list (also a longer one) → 409 source-conflict, always with the same generic
message and nothing about the stored source; the reason (other-feeder, source-differs,
attachments-differ) goes only to the process log. link is Link: http:/https: only.
6.3 Proposals and quote check#
const ProposalSource = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("jamie"), id: JamieId, quote: z.string().min(20).max(2000),
timestamp_s: z.number().int().nonnegative(), link: Link }).strict(),
z.object({ kind: z.literal("mail"), id: MailId, quote: z.string().min(20).max(2000),
link: Link.nullable() }).strict(),
z.object({ kind: z.literal("manual"), quote: z.string().min(1).max(2000), // own words
link: Link.nullable() }).strict(),
]);
const ProposalRequest = z.object({
scope: ScopeName, kind: z.enum(["rule", "note"]), statement: z.string().min(10).max(500),
source: ProposalSource, said_at: Instant, speaker: z.string().min(1).max(200).nullable(),
relations: z.array(Relation).max(20), keywords: Keywords, valid_from: Day,
supersedes: RuleId.nullable(),
}).strict();
const ProposalCreated = z.object({ ref: RuleRef, status: z.literal("proposed"),
quote_check: z.object({ checked: z.boolean() }), possible_duplicates: z.array(RuleRef).max(3),
approval_url: z.string().url() }); // <public_url>/approval/rules/<id>
const ProposalSummary = z.object({ ref: RuleRef, kind: z.enum(["rule", "note"]), statement: z.string(),
said_at: Instant, source: z.object({ kind: z.string(), id: z.string() }), supersedes: RuleId.nullable(),
created_at: Instant, approval_url: z.string().url() });
Order: jamie/mail source must exist in the scope (422 source-unknown); quote check (422
quote-not-found); supersedes must name a confirmed rule of the same scope (422
reference-unknown); then in one transaction: rule (seq = max + 1), rule_version v1
proposed with reason = "proposed via <channel>", changed_by = grant id, FTS row.
possible_duplicates: stage a over the same scope with text = statement, terms = keywords,
proposals included, the new rule excluded, top 3. Failed checks are counted by their search_log
row (operation = 'propose', outcome = 422). Slice A: both routes call intake()
(approval-modes.md §6): secret scan first (422 secret-detected), then this order, then
classification and, when the outcome is auto, adoption in the same transaction; ProposalCreated
gains adoption, valid, marking, reasons and status widens to proposed | confirmed.
Quote check (domain/quote-check.ts): normalize(s) = NFKC → lowercase → every \p{P} and
quote character to a space → collapse whitespace → trim. Normalized quote shorter than 20 chars →
not found. mail: normalized quote is a substring of normalized source.excerpt. jamie: the
window is all segments with start_s ∈ [timestamp_s − 120, timestamp_s + 120], joined in n order
with a space (speakers excluded); normalized quote must be a substring of the normalized window.
manual: no check, quote_check.checked = false.
6.4 Decision (decision/decide.ts)#
const Change = z.object({ statement: z.string().min(10).max(500), kind: z.enum(["rule", "note"]),
keywords: Keywords, relations: z.array(Relation).max(20), valid_from: Day, said_at: Instant,
speaker: z.string().min(1).max(200).nullable(), supersedes: RuleId.nullable() }).partial().strict();
const DecisionRequest = z.discriminatedUnion("decision", [
z.object({ decision: z.literal("confirm"), version: z.number().int().positive(), change: Change.optional(), reason: z.string().min(1).max(1000).optional() }).strict(),
z.object({ decision: z.literal("reject"), version: z.number().int().positive(), reason: z.string().min(1).max(1000) }).strict(),
z.object({ decision: z.literal("revoke"), version: z.number().int().positive(), reason: z.string().min(1).max(1000), valid_until: Day }).strict(),
z.object({ decision: z.literal("review"), version: z.number().int().positive(), change: z.object({ keywords: Keywords }).strict().optional(), reason: z.string().min(1).max(1000).optional() }).strict(),
]);
Checks in this order: change has any of source, quote, timestamp_s, source_link, link →
400 source-immutable; reject/revoke without non-blank reason → 400 reason-missing; Zod
→ 400 validation-failed; rule unknown or not in scope → 404; current status rejected or
superseded → 409 already-decided; version ≠ current → 409 version-outdated;
confirm/reject on confirmed → 409 already-decided; revoke/review on proposed → 400
validation-failed. Every 409 carries current: rule/<id>@<n>.
| Decision | New version | Effect |
|---|---|---|
confirm |
confirmed with change applied, reason = given or "confirmed" |
if supersedes = X: X must be confirmed now (else 409 predecessor-not-confirmed; unknown X → 422 reference-unknown), X's valid_from ≤ new valid_from (else 400), and in the same transaction X gets a version superseded, valid_until = new valid_from, superseded_by = new id, reason = "superseded by rule/<id>@<n>" |
reject |
rejected |
removed from rule_fts, added to rejected_fts; X of supersedes untouched |
revoke |
superseded, valid_until given (≥ valid_from, else 400), no successor |
— |
review |
none, or confirmed with new keywords if change.keywords differs |
resets the recheck clock |
One transaction per decision: version row(s), one decision row (decided_by = grant id,
decided_by_identity = email, new_version), FTS, search_log. last_reviewed_at of a rule =
latest decided_at of its confirm/review decisions (the policy's included), else
rule.created_at. Since slice A every decision row carries origin (grant for a grant's
decision, auto for the policy, decided_by = policy@<ruleset>); DecisionOut gains origin and
mode.
6.5 Search request and response#
const SearchRequest = z.object({
scopes: z.array(ScopeName).min(1).max(20), cross_scope: z.boolean().default(false),
text: z.string().min(1).max(20_000).optional(),
terms: z.array(z.string().min(1).max(100)).max(20).optional(),
relation: z.object({ kind: Relation.shape.kind, name: z.string().min(1) }).strict().optional(),
as_of: Day.nullable().default(null), include_proposals: z.boolean().default(false),
limit: z.number().int().min(1).max(50).default(10), // counts bundles
entity: z.string().regex(ENTITY_ID).optional(), // heads with a counted link to this entity or an ancestor
}).strict().refine(r => r.text !== undefined || (r.terms?.length ?? 0) > 0)
.refine(r => r.entity === undefined || (r.scopes.length === 1 && scopeOfEntityId(r.entity) === r.scopes[0])); // else 400
const StageState = z.object({ state: z.enum(["ran", "off", "failed", "budget", "not-allowed"]),
duration_ms: z.number().int().nullable(), reason: z.string().nullable() });
const EntryKind = z.enum(["rule", "note", "meeting", "mail", "attachment", "entity"]);
const EntryBase = z.object({ ref: z.string(), kind: EntryKind, date: Instant, title: z.string(),
excerpt: z.string().nullable(), link: z.string().nullable() });
const Entry = EntryBase.extend({ // a group entry
origin: z.enum(["deterministic", "proposed", "human"]), // of the link it hangs on
proposed: z.boolean() }); // proposed and not yet confirmed (graph.md G5)
const PathItem = z.object({ ref: z.string(), name: z.string(), type: z.string() });
const HeadEntity = PathItem.extend({ path: z.array(PathItem), proposed: z.boolean() }); // path root first
const Head = EntryBase.extend({
kind: z.enum(["rule", "note"]), scope: ScopeName,
status: z.enum(["confirmed", "proposed", "superseded"]), unconfirmed: z.boolean(),
valid_from: Day, valid_until: Day.nullable(), superseded_by: RuleId.nullable(),
last_reviewed_at: Instant, age_days: z.number().int(),
recheck: z.literal("still valid?").nullable(),
source: z.object({ kind: z.enum(["jamie", "mail", "manual"]), id: z.string(), quote: z.string(),
timestamp: z.string().nullable(), link: z.string().nullable() }), // timestamp "m:ss" / "h:mm:ss"
speaker: z.string().nullable(), relations: z.array(Relation),
rank: z.object({ a: z.number().nullable(), b: z.number().nullable(),
rrf: z.number().nullable(), reranker: z.number().min(0).max(1).nullable() }),
found_via: z.array(z.string()), // matched query words, parts, corrections, synonym phrases (sorted, no stems), later "semantic"
coverage: z.number().min(0).max(1).nullable(), // 7.6a; null for a read (GET /v1/rules/:id)
matched_terms: z.number().int().min(0).nullable(), // 7.6a; null for a read
entities: z.array(HeadEntity).max(10), // section 8; empty without graph data
});
const Group = z.object({
kind: z.enum(["source", "thread", "attachment", "entity", "predecessor", "successor", "contradicts", "same-relation"]),
count: z.number().int(), entries: z.array(Entry).max(5), more: z.number().int() });
const Bundle = z.object({ head: Head, groups: z.array(Group), line: z.string() });
const SearchResponse = z.object({
search_id: z.string().uuid(), scopes: z.array(ScopeName),
stages: z.object({ a: StageState, b: StageState, c: StageState }),
query: z.object({ terms: z.array(z.string()), variants: z.record(z.string(), z.array(z.string())) }),
bundles: z.array(Bundle),
abstained: z.object({ // null iff bundles is non-empty (7.6a)
reason: z.enum(["below-min-coverage", "below-reranker-threshold", "no-candidate"]),
best_coverage: z.number().min(0).max(1).nullable(), // null for no-candidate
min_coverage: z.number().min(0).max(1), // search.min_coverage
}).nullable(),
});
const FeedbackRequest = z.object({
bundles: z.array(z.object({ ref: RuleRef, verdict: z.enum(["relevant", "irrelevant"]) }).strict()).max(50),
missing: z.array(RuleRef).max(50) }).strict().refine(f => f.bundles.length + f.missing.length > 0);
stages is in every response; b or c not running is never an error (200). An empty bundles is
always explained by abstained (still 200): no-candidate when stage a found nothing that passes
the filters, below-min-coverage when candidates existed but none reached a configured
search.min_coverage > 0 (7.6a); the weak candidates are not returned, only the best coverage
(deviation D67). With the default min_coverage: 0 only no-candidate occurs.
below-reranker-threshold: stage c ran and no head reached search.reranker.threshold; where
stage c ran in a scope, its threshold replaces min_coverage there (stages-b-c.md §14.2, §14.3).
Feedback: every ref's scope passes steps 2–4 and must have a search_log row with this id,
operation = 'search' and the caller's grant id (else 404, the same answer as for an unknown id);
one feedback row per item in that scope.
Compact form (MCP search_rules only). The MCP tool takes SearchRequest plus
detail: "compact" | "full" (default compact) and answers detail: "full" with the
SearchResponse above. POST /v1/search has no detail and always answers the full
SearchResponse: API clients are programs that read fields, not a model with an output cap, and
one HTTP contract stays one schema. The compact form is built from the full response in
search/compact.ts, after search() wrote its search_log row:
const CompactGroup = z.object({ kind: Group.shape.kind, count: z.number().int(),
refs: z.array(z.string()).max(5), // the refs of the group's newest 5 entries
proposed_refs: z.array(z.string()).min(1).max(5).optional() }); // refs whose link is proposed (G5); absent when none
const CompactHead = z.object({ ref, kind, status, scope, title, quote, // quote = head.source.quote
date, valid_from, valid_until, recheck,
source: z.object({ kind, id, timestamp, link }), coverage }); // types as in Head
const CompactBundle = z.object({ head: CompactHead, groups: z.array(CompactGroup), line: z.string() });
const RelatedEntry = EntryBase.pick({ ref: true, kind: true, date: true, title: true });
const CompactSearchResponse = z.object({ search_id, scopes, stages, // stages complete, as in SearchResponse
query: z.object({ terms: z.array(z.string()) }), // no variants
bundles: z.array(CompactBundle), abstained,
related: z.array(RelatedEntry), // every entry the bundles name, once
truncated: z.object({ omitted: z.number().int().min(0), // whole bundles dropped from the end
omitted_refs: z.number().int().min(1).optional(), // group refs of the first bundle dropped
quote_cut: z.literal(true).optional() }).nullable() }); // first head's quote cut with "…"
The source group is left out (it is head.source); every other group keeps kind and count
and names its entries by ref; an entry that several bundles name (typically the same-relation
rules) appears once in related. line is unchanged, so counts and the "(of which p proposed)"
part stay. Not in the compact head: excerpt (= quote), link (= source.link),
unconfirmed (= status), superseded_by, last_reviewed_at, age_days (both in line),
speaker, relations, rank, found_via, matched_terms, entities (the entity group refs
and their path in related). Size cap SEARCH_COMPACT_MAX_CHARS = 30 000 characters of the
larger of the tool result's model text and its structured JSON: the practice test saw agents fail
on answers of 51 000 characters and more; ten bundles with 300-character quotes and a shared
relation need about 24 000 (full form about 115 000). Titles in related (mail subjects,
attachment names, entity paths: foreign text whose every sentence the model text frames) are cut to
RELATED_TITLE_CHARS = 120 characters ending in "…". Over the cap, whole bundles are dropped from
the end until it fits and truncated.omitted says how many; if the first bundle alone is over, its
group refs are dropped from the end (truncated.omitted_refs, count stays), then its quote is cut
with "…" (truncated.quote_cut). Each step is a binary search over the rendered size (about 25
renders at limit: 50). The first head with statement and line always stays, so a search with
hits never answers empty and abstained keeps its meaning.
search_log.refs lists every bundle the search produced, also the omitted ones. For a full
bundle the agent calls read_rule, or repeats the search with detail: "full" or a narrower
query.
RuleVersionOut mirrors rule_version (relations/keywords as arrays, source as
{kind, id, quote, timestamp_s, link}). DecisionOut = {kind, version, decided_by, decided_at, reason, new_version} (the identity email is shown only in the approval UI). Provenance =
{ref, source: {id, kind, title, occurred_at, sender, thread, link, fulltext_sha256, fetched_at}, quote, timestamp_s, excerpt, segments, thread: Entry[], attachments: [{ref, filename, media_type, size, external, text_origin, text}]}; excerpt is the mail text or the jamie window
([m:ss] speaker: text lines), segments the window for jamie, else null.
6.6 Errors#
Body ErrorBody. Codes are stable API.
| HTTP | error |
When |
|---|---|---|
| 400 | validation-failed |
body/query/form invalid (issues), forbidden transition by request shape |
| 400 | source-immutable · reason-missing · multiple-scopes-without-flag |
see 6.4, 5.2 |
| 405 | method-not-allowed |
GET/DELETE /mcp |
| 401 | unauthorized |
no/invalid credential, no active grant (+ WWW-Authenticate on /mcp, /v1/*); remedy names the next step (ask the operator for a grant, groundnote grants add …) with one text for every reason, so the answer never tells a wrong token from a known identity without a grant |
| 403 | scope-not-allowed · channel-not-allowed · csrf-failed |
5.2, 5.3 |
| 404 | not-found |
unknown id/search in an allowed scope; any rejected rule outside approval |
| 409 | version-outdated · already-decided · predecessor-not-confirmed |
with current |
| 409 | not-auto-adopted |
retract of a rule a person confirmed or reviewed, or of a proposal; with current (approval-modes.md §8) |
| 422 | secret-detected |
the proposal contains credentials; nothing stored, nothing of the value in the answer (approval-modes.md §5.2) |
| 409 | source-conflict |
repeat of a stored source id that is not identical or comes from another grant; one generic message, reason only in the process log (6.2) |
| 413 | payload-too-large |
body limit |
| 429 | rate-limited |
over a limits budget (section 3); header Retry-After in seconds |
| 422 | quote-not-found · source-unknown · reference-unknown |
6.3 |
| 503 | scope-locked |
4.3 |
| 500 | internal |
logged with a request id, no details in the body |
| ops | scope-not-configured (400) · scope-exists (409) · scope-unknown (400) · grant-exists (409) · not-found (404) · backup-running (409) · backup-directory-invalid (409) · export-directory-invalid (409) |
section 11 |
In MCP, scope errors are tool results with isError: true and the same error code; input
rejected by the SDK's schema check is an isError result with the text "Input validation error"
(deviation D45). A request to /mcp with a foreign Origin → 403 csrf-failed. Failures of the
JWKS fetch are 500 internal, not 401. A configured scope without a file → 503 scope-locked.
Startup refusals (process exits ≠ 0, no HTTP): team-domain-not-https, public-url-not-https
and the other reasons of section 3.
7. Search stage a (search/)#
Stage a is the lexical module search/lexical/ (LexicalIndex, options from search.lexical,
section 3), wired in search/stage-a.ts; its measures and numbers are in
search/lexical.md. Steps 1–4 are the module with the default switches; every
switch off reproduces the original single-index contract (D70).
- Terms. Text → NFKC, lowercase, split on
[^\p{L}\p{N}]+, drop de/en stopwords (persearch.languages), dedupe, keep the first 12. Withnormalize, canonical code and unit tokens of the text (RAL 9005→ral9005) come first. Key terms (keyterms): a text with more thanlong_text_tokens(25) content words keeps only itsmax_key_terms(6) most characteristic terms, weighted by rarity in the scope (IDF), repetition, capitalization and codes, without the mail frame; unknown words count only as compounds of known parts, so a long mail is not dominated by small talk. Giventermscome first, each as the phrase of its letter/digit tokens; total ≤ 20. Multi-word terms stay phrases. Terms depend on the scope's vocabulary; a cross-scope response lists the terms of all scopes in first-seen order. - Variants per term (
query.variants[term]): the term; spelling ä↔ae, ö↔oe, ü↔ue, ß↔ss; one inflection strip (de-ern -en -er -es -n -e -s, en-ing -ed -es -s, stem ≥ 3 chars); withdecompoundthe compound parts; withfuzzytypo corrections from the vocabulary. Synonym groups (synonyms: base list plussearch.synonyms) are listed under~<matched words>. - Sub-queries.
word(prefix variants onlex_word),stem(stems and compound parts onlex_stem),syn,fuzzy, optionaltriandprf; every token a quoted FTS5 literal (inner"doubled), user text never reachesMATCHunquoted. No terms → stage a ran, zero bundles. - Pool and fusion. Each sub-query ranks by
bm25(…, 4, 3, 2, 1)overstatus IN ('confirmed','superseded'[, 'proposed'])(lex_doc);fusion: "score"adds, per sub-query,sub_weights[name] × (−bm25 / best −bm25 of that sub-query); a single sub-query keeps −bm25.validitymultiplies by the version's standing on the target day: superseded or expired ×0.5, not yet valid ×0.8, proposed ×0.95, small bonus for recentvalid_from. Top 200 by fused value, ties byseq.rank.a= the fusion value, rounded to 4 decimals (higher is better); it is comparable within one search, not across searches or scopes. - Filter. Validity is by dates, not only by status. Target day =
as_ofif given, else today (date inconfig.zonefrom the injected clock). Aconfirmedorsupersededversion passes if it is in force on the target day:valid_from ≤ day < coalesce(valid_until, '9999-12-31'). Superseding sets the predecessor'svalid_untilto the successor'svalid_from, so a superseded rule stays the head (statussuperseded, successor in itssuccessorgroup) until its successor starts. A hit on asupersededversion no longer in force (valid_until ≤ day) is replaced by followingsuperseded_by(≤ 20 hops) to a rule in force on that day, else dropped.include_proposals: proposals pass withunconfirmed: true.rejectednever.relation: head has a relation with equal kind and case-insensitive name.entity: head has a counted link to the entity or one of its ancestors (section 8). - Dedupe by head rule id (best fusion value);
found_via= union of what matched the indexed text of the hit (same normalization as the index), sorted: a query word whose word, stem or spelling variant matched is named as the query spells it (never the bare stem), a compound part or typo correction only when the whole query word did not match, synonym expansions as phrases. 6a. Coverage, no default threshold. For the first 50 candidates the module computescoverage= share of the query's term weight (IDF in the scope; a phrase weighs as its rarest word) that the head matches, 0–1, 3 decimals, andmatched_terms= how many query terms it matches (any variant). Both are on every search head (nullpast the first 50 candidates and on reads). Stage a does not drop heads by default: a fixed threshold cost 38–50 % of the answerable queries ineval/lexical(D67). Only withsearch.min_coverage> 0 are heads withcoverage < min_coverage(or no coverage) dropped beforelimit, and an empty result saysbelow-min-coveragewith the best coverage. Whether nothing suitable was found is decided by the caller oncoverage/matched_terms, or later by stages b and c together; proactive hints (Phase 2) will rely on a+b+c or a configured minimum coverage, not on stage a alone. Takelimitbundles. - Cross-scope: each scope searched separately; results interleaved by per-scope position, ties in request order. Bundles never mix scopes.
- Stages b/c:
interface SearchStage { name: "b" | "c"; availability(scope): StageState | null; start({ query, deadline, signal }): StageRequest }, withStageRequest.run({ scope, db, candidates }): Promise<StageOutcome>(ranwith candidates, orfailed/budgetwith a reason).search()isasync; stage a runs while b embeds the query; a and b are fused by RRF, c reranks the top 50 heads per scope. Switched off, a stage reports{state: "off", duration_ms: null, reason: "disabled in config"}; a stage that throws isfailed(internal error) and the result is that of a (or a+b). Contract and deviations of the build: search/stages-b-c.md §4, §15. - Log per scope: terms, variants, stages, head refs,
input_sha256oftext— never the text.
8. Bundles (search/bundle.ts, search/line.ts)#
Head = matched rule version: ref rule/<id>@<n>, date = said_at, title = statement,
excerpt = quote, link = source link. age_days = calendar days (zone) since
last_reviewed_at; recheck = "still valid?" iff status confirmed and age_days > recheck_after_days. An entry excerpt of the source, thread and attachment groups is the
start of the text, at most 300 characters, ending in … when shortened; the head quote stays full.
head.entities (≤ 10) = the entities of the scope, not archived, that a counted concerns,
belongs_to or mentions link ties to the head rule, each with path root first (ref entity/<id>, name, type) and proposed (graph.md §11). Groups in this fixed order, empty
groups omitted, entries = newest 5, count = all, more = count − entries.length; every entry
carries origin (of the link it hangs on) and proposed (proposed, not yet confirmed):
| Group | Contents |
|---|---|
source |
the head's source (meeting for jamie, mail); none for manual |
thread |
other mail sources with the same thread |
attachment |
all attachments of the source, including unread ones (excerpt null) |
entity |
the entities of head.entities: ref entity/<id>, title = path joined with ›, date = link valid_from, excerpt/link null; not proposed first, then confidence, then newest |
predecessor |
rule named in supersedes (its latest version) |
successor |
rule named in superseded_by |
contradicts |
rules linked to the head by a counted contradicts link (either direction), passing the head filter |
same-relation |
other rules of the scope passing the same filter that share a relation (kind + name, or equal non-null ref) or have a counted link to a head entity or one of its ancestors |
Source, thread, attachment, predecessor, successor and relation entries are computed (origin: "deterministic", proposed: false). Counted links (G5): stored deterministic/human links
always; proposed links only when a person confirmed them, or when graph.proposed_min_confidence
is set and the confidence reaches it without a reject; rejected never; only links open on the
target day (as_of or today) and only ends in the bundle's scope. The search filter entity (one
scope, the entity's own; else 400) keeps heads with a counted link to the entity or one of its
ancestors.
line is built in code (English, dates in config.zone, ISO): <Label> from <YYYY-MM-DD>: <statement>[ — with <parts>]; <review>. Label Rule/Note, prefixed Unconfirmed for
proposals or Superseded for superseded heads. Parts in group order: 1 meeting/1 mail,
<n> mail(s) in thread, <n> attachment(s) (+ (<u> unread) if u > 0), <n> entit(y|ies), replaces <id>,
superseded by <id>, <n> contradicting rule(s), <n> related rule(s); when shown entries are
proposed, the parts end with (of which <p> proposed). Bundles without graph data are unchanged. Review: last reviewed <d> day(s) ago (+ , still valid? when recheck), for proposals proposed <d> day(s) ago, not confirmed. Example: Rule from 2026-03-12: Ask the supplier for the delivery time before ordering black hook rails — with 1 meeting, 2 attachments; last reviewed 220 days ago, still valid?
9. MCP (src/mcp/)#
SDK @modelcontextprotocol/sdk, Streamable HTTP server transport in stateless mode
(sessionIdGenerator: undefined, JSON responses), a fresh McpServer({name: "groundnote"}) per
request bound to the authenticated context, created with the instructions text of graph.md §9.1;
auth runs in Hono before the transport. Each tool calls the same domain/search/graph functions as
the API with channel mcp.
Output. Every successful result has structuredContent and content[0].text. The structured
content is the API object plus untrusted: true on every object that holds foreign text and
signals (injection signals, warning only) on every record with a ref
(annotateForeign, src/security/model-output.ts). The text is not the JSON: it is the
same result rendered for a model (renderResultForModel). Server-generated fields (refs, ids,
scope, status, dates, enums, approval_url, recheck, the bundle line) are written as is; a
statement is marked human-confirmed only while its record is confirmed and its adoption.kind is
person (an auto-adopted one stays in a block marked auto-adopted, approval-modes.md §9); every other string
(quote, excerpt, title, subject, speaker, entity name, alias, attachment name, keyword, query term)
is foreign and appears only inside a block ⟪gn:<id> untrusted …⟫ … ⟪/gn:<id>⟫ with a random
per-response boundary, escaped so it cannot close the block, after a fixed preamble that says the
content is data, not instructions. The rule is fail-closed: a field not on the server-field list
is foreign. Expected tool errors are plain JSON {error, …} (codes and request ids only);
graph refusals (remedy, next, candidates, paths) are framed like results because candidate
names are foreign text. The CLI prints through the same renderer.
| Tool | Right | Input | Output |
|---|---|---|---|
scopes |
read | — | {scopes: [{scope, locked, confirmed, proposed, stages: ["a"]}]} for the grant's scopes only; confirmed/proposed are null when locked |
search_rules |
read | SearchRequest + detail |
CompactSearchResponse (default) or SearchResponse (detail: "full"), §6.5 |
read_rule |
read | {ref: RuleRef} |
as GET /v1/rules/:id |
provenance |
read | {ref: RuleRef} |
Provenance |
propose_rule |
propose | ProposalRequest |
ProposalCreated |
open_proposals |
read | {scope} |
{scope, proposals: ProposalSummary[]} |
entity_resolve, entity_tree, entity_path |
read | graph.md §9.2 | guidance envelope (path, next) plus candidates, nodes or versions |
entity_create, entity_move, entity_update |
propose | graph.md §9.2 | guidance envelope plus the entity; takes effect at once as unconfirmed |
link_propose |
propose | graph.md §9.2 | guidance envelope plus the proposed link |
link_list |
read | graph.md §9.2 | guidance envelope plus links or groups |
rule_retract |
propose | {ref: "rule/<id>@<n>", reason} |
as POST /v1/rules/:id/retract; only auto-adopted rules (approval-modes.md §8) |
That is 15 tools (14 before slice A); no name matches /confirm|reject|revoke|review|decide|approve|archive|revert/ (AT4).
search_rules description, verbatim five sentences: "Cite every bundle with its date and source.
If a head carries recheck, ask the person "still valid?" before applying it and read
provenance first. If a stage did not run and nothing was found, say which stages ran. Prefer
rules that state a restriction for the action you are planning. Groups list refs, each entry once
in related; for a full bundle call read_rule or pass detail: \"full\", and if truncated is
set, search narrower for the omitted bundles." Asked to confirm, the model
answers with the approval_url. The verbatim descriptions of the graph tools are in graph.md §9.2.
Server instructions (src/mcp/instructions.ts, verbatim in graph.md §9.1) tell the model to
name the scope, call entity_resolve before entity_create, tell the person the full path, say
that created or moved structure is unconfirmed and give the approval_url, never confirm anything,
read trees step by step, follow next and remedy, and cite date and source of every rule.
Prompts (src/mcp/prompts.ts, five, one user message each; texts in graph.md §9.4):
create_level, file_incoming_mail, assign_document, restructure, where_does_this_belong.
Guide and example dialogues: agents.md.
10. Approval UI (src/approval/)#
| Route | Shows |
|---|---|
GET /approval |
open proposals of every grant scope, oldest first |
GET /approval/rules/:id |
current version (also rejected, read-only), quote with link (jamie: minute), possible duplicates (stage a, proposals included), previously rejected similar proposals (rejected_fts, top 5, with reason, person, date), the rule to supersede side by side, all versions and decisions, forms: confirm (statement, kind, keywords, valid_from, reason), reject (reason), revoke (reason, valid_until), review (keywords) — each with version and csrf |
GET /approval/rejected?scope= |
rejected proposals newest first: statement, quote, reason, who, when |
GET /approval/exceptions?scope= · GET /approval/auto?scope=&since= |
the exceptions page and the list of auto-adopted rules (approval-modes.md §10) |
GET /approval/style.css |
static CSS |
Forms post to POST /v1/rules/:id/decision (form-encoded; keywords comma-separated). Relations are
edited through JSON on the same route.
11. Ops listener and CLI (src/ops/)#
Separate Hono app on the Unix socket $GROUNDNOTE_DATA_DIR/ops.sock, mode 0600, Authorization: Bearer $GROUNDNOTE_OPS_TOKEN (constant-time). There is no TCP listener for ops: only the user that
owns the data directory can connect, and the public listener has no ops routes.
| Route | Body → result |
|---|---|
POST /ops/scopes |
{name} → 201; name not in config → 400 scope-not-configured; file exists → 409 scope-exists. Creates the file, runs migrations, writes meta, inserts backup_state (count 0) |
POST /ops/grants |
{kind: "person", email, scopes, rights, label?} · {kind: "service", auth: "bearer", scopes, rights, label?} · {kind: "service", auth: "access-service", common_name, scopes, rights, label?} → 201 {id, token?}; bearer plaintext (gn_ + 32 random bytes base64url) is returned once, only its SHA-256 is stored; decide for service → 400 validation-failed; a scope without file → 400 scope-unknown; the same auth + identity with an active grant → 409 grant-exists |
POST /ops/grants/:id/disable |
→ 200; unknown → 404. Grants are never re-enabled |
POST /ops/vector-index/:scope/rebuild |
→ 202 {scope, model, queued}; scope without file → 400 scope-unknown; stage b unavailable → 409 semantic-unavailable. Re-embeds every current version in place |
GET /ops/vector-index |
→ 200 [{scope, model, total, embedded, other_model, running, last_error}] |
POST /ops/backup/verify |
{scope?} → 200 {ok, scopes: [{scope, ok, checked_at, sources: [{source, ok, checks: [{check, ok, detail?}], counts}], exceptions}]}; a named scope without file → 400 scope-unknown; no scope → every created scope |
GET /ops/status |
→ 200 OpsStatus (below): the service's own facts for groundnote doctor; no verdicts except what only the service can compute |
GET /ops/grants?scope=&all= |
→ 200 {grants: GrantOut[]} in seq order; default active only, all=true includes disabled; scope filters to grants that hold it |
POST /ops/backup/run |
{} → 200 RunAnswer (11.1); 409 backup-running, 409 backup-directory-invalid |
POST /ops/backup/probe |
{scope?} → 200 ProbeAnswer (11.2); a named scope without file → 400 scope-unknown; 409 backup-running, 409 export-directory-invalid |
Run, probe and verify share one in-process lock: a second call while one runs → 409 backup-running. They read the live files through a read-only connection in a worker thread, so
the public listener keeps answering during a long VACUUM INTO (better-sqlite3 is synchronous);
writes stay with the service's own handles (section 4: the service is the only writer). Paths in
ops answers are fine (the socket is the data dir owner's); they never reach /v1/*.
const Finding = z.object({ id: z.string(), status: z.enum(["ok", "warn", "fail", "skip"]),
message: z.string(), remedy: z.string().nullable() }); // cli.md §6
const BackupRunOut = z.object({ seq: z.number().int(), kind: z.enum(["run", "probe"]),
started_at: Instant, finished_at: Instant, ok: z.boolean() });
const OpsStatus = z.object({
service: z.literal("groundnote"), instance: ScopeName, version: z.string(),
started_at: Instant, now: Instant, data_dir: z.string(), // real path
backup: z.object({ kind: z.enum(["copy", "litestream"]), directory: z.string().nullable(), // real path, absolute
export_directory: z.string().nullable(), // against the service's cwd while missing, like data_dir
running: z.enum(["run", "probe", "verify"]).nullable(),
ops_backup_at: Instant.nullable(), last_run: BackupRunOut.nullable(), last_probe: BackupRunOut.nullable() }),
scopes: z.array(z.object({ scope: ScopeName, created: z.boolean(), locked: z.boolean(),
lock_reason: z.string().nullable(), version_count: z.number().int().nullable(),
last_backup_at: Instant.nullable(), verified_at: Instant.nullable(), verify_ok: z.boolean().nullable(),
probed_at: Instant.nullable(), probe_ok: z.boolean().nullable(),
exported_at: Instant.nullable(), export_sha256: z.string().nullable() })), // config order
usage: z.object({ reranker_month_usd: z.number() }).optional(), // spentThisMonthUsd in the instance zone; read by doctor (cli.md §6)
});
const GrantOut = z.object({ id: z.string(), kind: z.enum(["person", "service"]),
auth: z.enum(["email", "access-service", "bearer"]),
identity: z.string().nullable(), // e-mail or Access common name; null for bearer
fingerprint: z.string().nullable(), // bearer only: "sha256:" + first 12 hex of the stored hash
scopes: z.array(ScopeName), rights: z.array(z.enum(["read", "propose", "decide"])),
label: z.string().nullable(), active: z.boolean(), created_at: Instant, disabled_at: Instant.nullable() });
11.1 Backup run (src/backup/run.ts, POST /ops/backup/run)#
litestream: nothing is copied; 200 {ok: true, kind: "litestream", ran: false, message: "Litestream replicates continuously; nothing to copy"} and a backup_run row. copy:
backup.directorymust be an existing directory owned by the service user with mode0700, not inside the data dir (real paths); else409 backup-directory-invalidnaming the reason.<directory>/scopes/is created0700if missing.ops.sqlitefirst, then every created scope in config order. The order matters for invariant 7: every scope copy is then at least as new as the version count theops.sqlitecopy remembers, so restoring the pair never locks a scope.- Per file:
VACUUM INTO <target dir>/.<file>.tmp-<uuid>(a consistent, compact snapshot; the live file is not vacuumed, section 4),inspectFileon the result (integrity,meta-scope; ops:integrityand a knownschema_version), then no regression: every append-only count ≥ the larger of the count of the copy it replaces (read before) and, for a scope,backup_state.verified_counts(the floor of the last passed verify or probe) — a shrunken live file never overwrites the last good copy. A previous copy that exists but cannot be read (SQLITE_CORRUPT,SQLITE_CANTOPEN,SQLITE_BUSY…) fails the item (no-regression: previous copy unreadable: <code>) and stays untouched. A created scope without abackup_staterow is not copied (backup-state-missing). Thenfsync,renameover<directory>/scopes/<scope>.sqliteor<directory>/ops.sqlite,fsyncof the directory, mode0600. Any failure removes the temporary file and keeps the previous copy. - A locked scope (4.3) is not copied (its item fails, the previous copy stays).
backup_state.last_backup_atper copied scope; onebackup_runrow with the answer.
const RunAnswer = z.object({ ok: z.boolean(), kind: z.enum(["copy", "litestream"]), ran: z.boolean(),
started_at: Instant, finished_at: Instant, message: z.string().optional(),
ops: z.object({ ok: z.boolean(), file: z.string(), bytes: z.number().int().nullable(), detail: z.string().optional() }).nullable(),
scopes: z.array(z.object({ scope: ScopeName, ok: z.boolean(), file: z.string(),
bytes: z.number().int().nullable(), detail: z.string().optional() })) });
11.2 Restore probe (src/backup/probe.ts, POST /ops/backup/probe)#
The daily restore drill (backup.md "Restore drill"), through the real restore path:
- A throw-away directory
<data dir>/.probe-<random>(0700), removed in every case afterwards. ops.sqlite: fetch the newest backup (copy:<directory>/ops.sqlite;litestream:litestream restore -o <tmp>/ops.sqlite <data dir>/ops.sqlite), checkintegrityandschema_version.- Per created scope (or the named one):
restoreScopeinto<tmp>/<scope>.sqlite(the checks of section 11 "Restore" incl. the count floor), thenpair-consistent(the restored file has at least thebackup_state.version_countof the restoredops.sqlite, else restoring both would lock the scope) and, against aVACUUM INTOsnapshot of live,not-ahead-of-liveandexport-hasheswithexceptionsexactly as in verify step 2. - If every check of the scope passed and
backup.export_directoryis set:buildExportof the restored file (so the archive is the proven backup state) →exportArchive→<export_directory>/<scope>/<scope>-export-<UTC stamp with milliseconds>-<8 hex>.tar.gzplus<file>.sha256(two probes in one second never collide) (sha256sumformat), each written to a temporary name and hard-linked (no overwrite), mode0600, directories0700. The directory must exist, be the service user's, not be writable by group or others unless sticky, and not lie in the data dir;<scope>/(and<directory>/scopes/ofbackup run) is a real directory of the service user with mode exactly0700, no symlink (409 export-directory-invalid/backup-directory-invalid). Groundnote never deletes old archives (retention is the operator's backup tool). backup_state.probed_at,probe_ok,probe_result, on successverified_counts(the floor) andexported_at,export_sha256; onebackup_runrow.
const ProbeAnswer = z.object({ ok: z.boolean(), kind: z.enum(["copy", "litestream"]), checked_at: Instant,
ops: z.object({ ok: z.boolean(), checks: z.array(Check) }), // Check = {check, ok, detail?}
scopes: z.array(z.object({ scope: ScopeName, ok: z.boolean(), checks: z.array(Check),
exceptions: z.array(z.string()),
export: z.object({ file: z.string(), bytes: z.number().int(), sha256: z.string() }).nullable() })) });
The archive is plaintext like every export today: it holds rejected rules and deciding persons'
e-mails (4.4). It is written for the holder of the ops token, who owns the data dir and can read
every SQLite file anyway, so it widens nobody's access (D94); keep export_directory on the
encrypted volume. Encryption to the scope key follows with slice R5 (redaction.md §9).
CLI groundnote (full reference: cli.md; reads GROUNDNOTE_DATA_DIR, GROUNDNOTE_OPS_TOKEN; sends the token only after
resolving the data dir to its real path: the dir must be no symlink, owned by the calling user
with mode 0700, every parent owned by root or the user and not writable by others unless
sticky, and ops.sock a real socket of the user, else ops-socket-untrusted; a missing dir or
socket is ops-unreachable, exit 1; the connect uses the resolved path). Commands:
| Group | Commands | Goes to |
|---|---|---|
read (api, read) |
search, get, bundle, provenance, proposals list, entity resolve|list|tree|path, link list, scopes, status |
section 6, graph.md §10 |
write (api, propose) |
propose, rule retract (only auto-adopted versions, approval-modes.md §8), entity create|move, link propose, sources add, sources get (own sources) |
section 6, 6.2a |
person (approval, decide) |
export |
4.4 |
| ops (ops socket) | scope create, grants list|add|disable, backup run|probe|nightly|verify, vector-index rebuild|status |
this section, 15.4 |
| local | restore, doctor, model fetch|verify (downloads and checks the stage-b model files by SHA-256), config show|path|init, approvals open (prints a link only), serve, version |
cli.md |
There is no command that confirms, rejects, revokes, reviews, archives, reverts or redacts
(invariants 2 and 10, AT6). Every api and approval command is a thin client with the caller's
credential and the server's own request schemas (cli.md §7). export is a thin client of GET /v1/export/:scope with the same rights model, so it needs a
person's Access token: credentials from GROUNDNOTE_URL + GROUNDNOTE_ACCESS_JWT (sent as
cf-access-token and cf-access-jwt-assertion; api commands use GROUNDNOTE_API_TOKEN or a
person's GROUNDNOTE_API_ACCESS_JWT, exactly one) or ~/.config/groundnote/cli.json ({url, token?, api_access_jwt?, access_jwt?}, $XDG_CONFIG_HOME honoured; refused unless a regular file
of the user with mode 0600, credentials-file-permissions); https only, http only for
loopback; the file is written only by config init (secret from stdin, never argv), with mode
0600, and never overwritten. Exit codes: 0 ok, 1 error (code on stderr), 2 usage, 3 a check
failed (D98). stdout carries only the --json answer (also an error body), a new service token
once, or the bare value of version, config path and approvals open; tokens are never printed
otherwise. Human output goes to stderr, foreign text in it through renderResultForModel.
Backup verify (src/backup/verify.ts), per scope, temporary files in a .verify-* directory
inside the private data dir, removed afterwards:
live-snapshot:VACUUM INTOfrom the live handle (reads only; the source file is not vacuumed, so section 4 "noVACUUM" holds), thenintegrity,meta-scope,sample-query(the newest rule with a search row is found by a word of its statement throughrule_fts; a scope without one says so) andversions-remembered(the D63 count ≥backup_state.version_count).copyorlitestream: fetch the newest backup into a temporary file (fetch-copy/fetch-litestream; a non-zero exit, or exit 0 without a file, fails), the same three checks,not-older-than-last-verified(every append-only count ≥verified_counts),not-ahead-of-live(no append-only table has more rows than live) andexport-hashes: both files are exported and the SHA-256 of every<id>.mdcompared. A rule may differ only if its newestchanged_at/decided_atis after the backup's newest one; it is named inexceptions, everything else fails. Append-only tables are those with aBEFORE DELETE … 'append-only'trigger, read from the schema.- The report goes to
backup_state(verify_result), the floor moves only on success.
Restore (src/backup/restore.ts, runs in the CLI, needs no running service):
groundnote restore --to <path> --scope <s> [--from litestream|copy] (default backup.kind)
resolves the target's directory to its real path and refuses the live file (target-is-live),
anything inside data/scopes/ (target-in-scopes-dir, also through symlinks), an existing target
or -wal/-shm/-journal next to it (target-exists) and a missing directory. It fetches into a
temporary file next to the target, runs the checks of step 2 that need no live file and the count
floor from ops.sqlite, and only then hard-links the file to the target (no overwrite, also not
by a race). A failed check writes nothing (exit 3). Warnings: no backup_state row (restore
ops.sqlite first), or fewer rule versions than ops.sqlite remembers (the service would lock
the file, invariant 7). --from export is a later phase (section 12).
12. Reserved for later phases#
The items below are not built. Scope, files, constraints and spec of the next slices: section 15.
| Phase | Item |
|---|---|
| 2 | Webhooks Jamie/Graph (/v1/events/jamie/:feeder, /v1/events/graph/:feeder), feeders config, auto-fetch of jamie sources, capture flag, extraction (extraction table, models, budgets, 429 budget-reached), LLM term generation |
| 2 | Litestream supervision by the service, groundnote restore --from-export, test set (test_case, test_run). The copy job, the nightly export and the daily restore probe with dead-man ping are specified in section 11.1, 11.2 and cli.md §8 (slice 15.3), scheduled by an OS timer, not by the service (no export_time, D93) |
| 3 | Budgets per context (extraction; ai_usage exists for stage c only). Stage b (vectors in the scope file, table vec_rule, D101; POST /ops/vector-index/:scope/rebuild) and stage c (JEV reranker, ai_usage) are built (15.4) |
| 4 | Proactive outputs (inbox, notice_reported, outbound_log, outputs config, proactive flag, search by inbox) |
13. Slice plan#
Each slice ends green on pnpm lint && pnpm typecheck && pnpm test and is one commit.
| # | Slice | Acceptance tests |
|---|---|---|
| 0 | Tooling (pnpm, tsconfig strict ESM, ESLint flat, Vitest, better-sqlite3 build allowed) | empty test suite runs; pnpm typecheck passes |
| 1 | config/ |
each refusal of section 3 fails start with a named reason; strict unknown key; semantic.enabled: true rejected; both backup kinds accepted; secrets never in error output |
| 2 | db/ migrations, scope store, ops store, FTS helper |
UPDATE/DELETE abort on all six append-only tables; version gap, terminal state, invalid transitions, source-immutable abort; CHECKs of 4.1; rule id outside scope aborts; meta.scope mismatch → locked; fewer versions than backup_state → locked; grant update other than disable aborts; decide on service grant aborts |
| 3 | ops/ listener + CLI |
listener only on ops.sock (mode 0600, no TCP); stale socket replaced, live one refused; wrong/missing ops token 401; scope create/exists/not-configured; grant add returns token once and stores only the hash; disable is final |
| 4 | auth/, http/app.ts, errors, /health, well-known |
401 + exact WWW-Authenticate; JWT with another channel's aud, wrong iss, expired, HS256 → 401; service token on approval → 403; scope-not-allowed identical for existing and missing scope; multiple scopes → 400; right missing → 403 channel-not-allowed; dev bearer and service bearer; both credentials → 401; /health shape |
| 5 | POST /v1/sources |
mail with readable and unreadable attachment; jamie with segments; repeat call adds only missing attachments and changes nothing; scope rights; 413 |
| 6 | Search stage a + bundles (fixtures via test helpers) | variants (ä/ae, ß/ss, stems, synonyms, prefix); FTS syntax in input (", NEAR, *, :, AND) is inert; default/include_proposals/as_of filters; superseded → successor; rejected never; limit counts bundles; group order, count, more; line text; recheck; stages b/c off; search_log has hash, not text; cross-scope interleave |
| 7 | Proposals + quote check | jamie quote inside/outside ±120 s; mail substring; normalization (case, punctuation, NFKC, whitespace); < 20 chars; manual unchecked; source-unknown; reference-unknown; ids <scope>-0001, -0002; possible_duplicates; 422 logged |
| 8 | Decision route + CSRF | confirm (with/without change); source-immutable; reason-missing; version-outdated; already-decided after reject; reject removes from rule_fts, adds to rejected_fts; revoke; review without/with keywords; supersede transaction + predecessor-not-confirmed for the second successor; CSRF missing/wrong/cross-origin → 403; MCP-aud JWT → 401 |
| 9 | Approval UI | pages render escaped content; rejected visible only here; rejected-similar shown; dev login/logout; CSP header |
| 10 | Read routes, status, feedback | rejected id → 404 on every read route; ?version=; pagination; status counts; feedback stored, foreign search id → 404 |
| 11 | MCP | tools list is exactly the six (14 since graph slice G4); propose/search via MCP; AT1–AT4 |
| 12 | E2E "black hooks" (cloudflare-access mode, local RSA key + local JWKS) | store jamie source with segments → propose_rule with quote "bei den schwarzen Haken müssen wir immer erst die Lieferzeit abfragen" → confirm via approval JWT + CSRF → search_rules with text "Haken in RAL 9005, tiefschwarz" returns one bundle whose head is the rule, with source group, said_at date and stages; counter-checks: a rejected proposal never appears; no MCP decision tool; MCP JWT on the decision route rejected; no token → 401 with resource_metadata |
Slices G1–G9 of the knowledge graph (graph.md §14) follow slice 12.
14. Knowledge graph and agent guidance#
Specified in graph.md and built (slices G1–G8; G9 is agents.md), from the German contract's section
"Wissensgraph" (rules G1–G8) and API section H. In short: migration 0002-graph adds entity,
entity_version (append-only history; a move is a new version), the derived entity_closure
(maintained in the same transaction), entity_fts (trigram), link (stored links only; thread,
attachment, source and supersedes links are computed) and review (person verdicts). Every graph
answer carries the full path and next steps; every error an error code with remedy.
Structure written by agents takes effect at once and appears in the approval section "Structure
changes" until a person confirms, reverts or archives it (invariant 10). Bundles gain
head.entities with paths and the groups entity and contradicts; proposed links count only when
confirmed or above graph.proposed_min_confidence, always marked proposed.
15. Next slices#
Nothing here is built. Each entry names scope, what it touches, constraints and the spec to read
first; a slice ends green on pnpm lint && pnpm typecheck && pnpm test, is one commit per
reviewable step, and changes the contract (this file, then abweichungen.md) before the code.
15.1 Approval modes A, B, C (specified: approval-modes.md, slices A1–A6)#
Scope. Per scope and optionally per sensitive class: autonomous (A), classified (B, project
default) and manual (C, today's behaviour and the default without an approval block). A
deterministic, versioned ruleset classifies every proposal; the policy writer
src/decision/policy/** adopts what passes, mandatory exceptions (payment, secret, contradiction,
external-sensitive, injection, unchecked quote, doubt, large subtree) always wait for a person,
auto-adopted rules are marked in every answer, retractable over MCP, API and CLI, limited per day
and logged in classification + decision. Changes invariants 1, 2, 11 and adds 12; AT1–AT4
as in approval-modes.md §14. Not built: digest, model classifier, structure adoption (D81–D83).
The operator decided the open questions on 2026-11-06 (approval-modes.md §16); structure adoption in A/B is the next slice.
15.2 Redaction and encryption (docs/redaction.md, slices R1–R7)#
Scope. Erasure of personal content by tombstone (rows stay, content goes), secure delete,
per-scope age keys for backups and exports, volume encryption as a mandatory operating step,
groundnote doctor. This also covers threat F6 of the threat model.
Order. R1 storage hygiene (secure_delete, temp_store, FTS5 secure-delete) → R2 tombstone
schema (one trigger exemption, property-tested) → R3 redaction core (src/redaction/**) → R4
approval flow (preview, then confirmation, person with decide) → R5 keys and encrypted
export/copy (src/keys/**, groundnote key …, groundnote backup run) → R6 doctor → R7 docs.
R5 depends only on R1.
Touches. src/db/connection.ts, new scope and ops migrations, src/approval/**,
src/backup/**, src/export/**, invariants 1, 3 and 9. Constraint. Redaction is reachable only
from the approval channel; no MCP tool, ops route or CLI command.
Seven open questions for the operator (docs/redaction.md §16): doctor backup age 24 h or 26 h;
exports encrypted to a reader's own recipient; a separate redact right; refusing plaintext
copies once R5 lands; at_rest required in groundnote.json; message ids stay as identifiers;
Litestream reseed or refusal when the backup kind is litestream.
15.3 Full CLI and groundnote doctor#
Status: built on feat/cli (steps 1–11 below; of step 12 cli.md, this section and backup.md
are updated, README and glossary follow). Specified in cli.md, section 11 (ops routes,
11.1 backup run, 11.2 restore probe), 6.2a (GET /v1/sources/:id), 4.2 (ops migration 0003),
section 3 (at_rest, backup.export_directory, doctor), AT6 and D92–D99. The stage checks of
doctor are wired since the merge with search-stages (cli.md §6).
Steps (one commit each, green on pnpm lint && pnpm typecheck && pnpm test): (1) schema
modules moved, AT6; (2) CLI core: route table, api client, --json, credentials incl.
api_access_jwt and credentials-ambiguous, config show|path|init; (3) read commands; (4)
write commands with --body and client-side validation; (5) GET /v1/sources/:id, sources get; (6) ops: GET /ops/status, GET /ops/grants, grants rename; (7) migration 0003, backup run; (8) backup probe with nightly export; (9) backup nightly with dead-man ping, examples;
(10) doctor (pure checks first, stage checks wired after the search-stages merge); (11)
/v1/status checks; (12) docs (README, glossary, backup.md "Phase 1 status").
Constraints. Exit codes stay 0 ok, 1 error, 2 usage, 3 a check failed (D98); credentials from
the environment or ~/.config/groundnote/cli.json with mode 0600, secrets never in argv; stdout
carries only the answer, an error body under --json, or a new token once; no command that
confirms, rejects, revokes, reviews, archives, reverts or redacts, ever (invariants 2 and 10,
AT6). Human output goes through src/security/model-output.ts. Touches src/ops/**,
src/backup/ (new run.ts, probe.ts), src/db/ops-store.ts, src/db/migrations/ops/0003-*,
src/config/file.ts, src/http/routes/sources.ts, src/http/routes/status.ts, the three schema
moves. Agreed with siblings: search-stages owns the semantic/reranker schema and
spentThisMonthUsd; redaction R6 adds its doctor checks to the same table and decides whether
at_rest becomes required.
15.4 Search stages b and c (done)#
Built on feat/search-stages; kept here as the record of the cut. Current behaviour and deviations: search/stages-b-c.md §15; measurement §16.
Scope. Stage b (local embeddings; vectors in the scope file, table vec_rule keyed by seq and
version, one model id and dimension per row, D101; fusion with stage a by RRF) and stage c (JEV
reranker; provider and configuration are decided in providers.md). Both register
through SearchStage (7.8) and report ran | off | failed | budget | not-allowed. Module cut,
schemas and flow: search/stages-b-c.md.
Touches src/search/stage.ts, src/search/search.ts, src/config/file.ts (semantic,
reranker leave the Off schema), rank.b, rank.rrf, rank.reranker. Constraints.
providers_allowed per scope is checked before any text leaves the host; a scope never reaches
stage c without the provider listed; stage a stays the recall floor. The coverage signal (7.6a) is
the base for "nothing suitable"; the relevance threshold is re-measured on real data once b or c
runs (see 15.5).
15.5 Smaller open items#
| Item | What and where | Spec / note |
|---|---|---|
restore --from export |
Rebuild a scope from an export archive. Needs a decision first: the export is never read back (4.4) and history.jsonl rows would have to be inserted into append-only tables, which AT3 forbids outside the writers |
section 4.4, D71, src/backup/restore.ts |
| Nightly export and daily restore probe | Built in 15.3: backup nightly (run, probe with export, dead-man ping) from an OS timer; examples in examples/systemd/, examples/launchd/ |
section 11.1, 11.2, cli.md §8 |
| Entity update from the CLI | entity_update (rename, aliases) has an MCP tool but no HTTP route, so the CLI cannot offer it; add POST /v1/entities/:id/update first if needed |
graph.md §10 |
| Litestream restore against the real binary | fetch-litestream is tested with an injected runner (the binary is not installed in the test environment); run it against Litestream 0.5.x |
src/backup/fetch.ts, backup.md |
| Evaluation run | eval/ sets exist (pnpm eval:lexical); a run on the scope's own test set (test_case, test_run) is Phase 2 |
search/lexical.md |
| Mbox import | Feeder that turns a mailbox file into POST /v1/sources calls |
section 6.2 |
| Person edits entities directly | A person creates or moves entities on the approval page (created_by = 'human', active at once); no route exists, so human links and creates have no writer |
graph.md §15 question 3 |
| Source language (D58) | Optional language on POST /v1/sources; approval pages render quotes with lang="" until then |
abweichungen.md D58 |
| Bulk confirm of a structure | Confirm a whole proposed hierarchy in one decision instead of one review per entity and link; until then limits.decisions_per_minute (default 60) bounds it |
section 3, G9 (test/e2e/graph-hierarchy.test.ts) |
| Relevance threshold re-measure | Measure coverage / matched_terms cut-offs on real data before any default for search.min_coverage |
D67, search/lexical.md |