Groundnote — knowledge graph and agent guidance#
Status: built (slices G1–G8; the agent guide G9 is agents.md). Architect draft of 2026-11-05. Extends architecture.md (§14
links here) and becomes part of the binding contract once accepted. Source: the German contract,
datenmodell.md section "Wissensgraph" (rules G1–G8) and api-vertrag.md section H, decided by
Ruben on 2026-11-05. Places where this file departs from it are D59–D66 in
abweichungen.md; new terms are in glossary.md.
A small graph inside each scope file: entities in one tree of any depth, with history, and links
with an origin between rules, sources, attachments and entities. Queries use the closure table or
WITH RECURSIVE; no graph database.
1. Decisions#
| # | Context | Decision | Rejected alternative (why) | Consequence |
|---|---|---|---|---|
| K1 | The contract says "area"; this repo says scope (glossary). | Area = scope. entity.scope must equal meta.scope (trigger). Ids <scope>-e<nnnn>, <scope>-l<nnnn>, <scope>-r<nnnn>; refs entity/<id>, entity/<id>@<n>, link/<id>. |
Column area (two words for one concept in one API). |
A cross-scope link cannot be stored: both ends must exist in the same file (G1). |
| K2 | One writer per relation (contract, "Berechnete Links"). | Relations that already have a column are computed on read (replies_to, attachment_of, sourced_from, supersedes, origin deterministic); table link stores only proposed, human and code-written links without a column (same_as for equal Paperless ids). |
Copying thread/attachment/supersedes into link (second writer, drift). |
link_list and bundles merge both kinds; no deviation entry needed (D-b is in the contract). |
| K3 | Re-parenting must keep history (G3). | entity + append-only entity_version; every create, move, confirm, revert, archive is a version. entity_closure is derived and rebuilt for the moved subtree in code, in the same transaction (src/graph/closure.ts, single writer). A trigger refuses cycles using the closure. |
Materialized path (rewrites every descendant row on a move); closure maintained by triggers (contract puts it in code; FTS is maintained the same way). | Closure can be rebuilt from entity_current.parent_id at any time (test G1). |
| K4 | Confirm, archive, revert are person-only (G6, G7). | New module src/structure/ (sibling of src/decision/), only writer of review rows and of channel = 'approval' versions; reachable only from the approval channel; never from src/mcp/** (AT1, AT2b). |
Putting it into src/decision/ (mixes rule lifecycle with structure). |
MCP has no confirm/archive/revert tool (AT4). |
| K5 | Rule relations (D-e) vs G8. | G8 wins: relations[] stays the proposal's text; resolving it to an entity is a stored concerns link (proposed by an agent after entity_resolve), reviewed on the rule's approval page. Not computed from Relation.ref. |
Computing concerns from Relation.ref (free text; no review). |
The orchestrator default D-e is replaced. |
| K6 | Agents must not stall (G6). | Agent writes take effect at once: create → unconfirmed, move → new version, link → proposed. Pending = agent versions and proposed links without a review. |
Holding changes until approved (agents cannot continue). | Section "Structure changes" in approval (§12). |
| K7 | Contract GraphError uses code and snake_case. |
Graph errors use the existing ErrorBody field error with kebab-case codes (duplicate-candidate, cycle, scope-mismatch, depth-limit) plus remedy, candidates, paths, next (D60). |
A second error shape in one API. | Clients branch on one field. |
| K8 | search_log.operation is a CHECK enum; SQLite cannot alter a CHECK. |
0002-graph rebuilds search_log and feedback in its transaction (§3.1). |
A second log table (D19 chose one log). | Migration test with existing rows. |
| K9 | Invariant 7 counts only rule_version. |
Loss detection counts rule_version + entity_version + link + review (D63). |
Ignoring graph rows (a restore that loses structure would pass). | Same column backup_state.version_count; monotonic from the old value. |
| K10 | "Agent version without review" must be decidable. | entity_version gets change and channel columns (D62). |
Inferring the kind from reason text or the grant (a person grant writes via MCP as agent). |
Pending query is exact. |
| K11 | Resolve needs aliases and typos. | FTS5 entity_fts (tokenize = 'trigram') over normalized name and aliases for recall, scored in code (§5). |
LLM matching (not deterministic); full scan only (no recall bound). | Thresholds are constants, tested. |
Slices G1–G9 follow slice 12 of the core plan (D-f).
2. Ids and refs (extends architecture.md §4.3, domain/ids.ts)#
const EntityId = z.string().regex(/^[a-z][a-z0-9-]{1,39}-e\d{4,}$/); // sales-e0007
const EntityRef = z.string().regex(/^entity\/[a-z][a-z0-9-]{1,39}-e\d{4,}(@[1-9]\d*)?$/);
const LinkId = z.string().regex(/^[a-z][a-z0-9-]{1,39}-l\d{4,}$/); // sales-l0042
const ReviewId = z.string().regex(/^[a-z][a-z0-9-]{1,39}-r\d{4,}$/);
const Endpoint = z.union([ // versionless; stored in `link`
z.string().regex(/^rule\/[a-z][a-z0-9-]{1,39}-\d{4,}$/),
z.string().regex(/^source\/(jamie:[A-Za-z0-9._-]{1,200}|mail:<[^<>\s]+>)$/),
z.string().regex(/^attachment\/(jamie:[A-Za-z0-9._-]{1,200}|mail:<[^<>\s]+>)#[1-9]\d*$/),
z.string().regex(/^entity\/[a-z][a-z0-9-]{1,39}-e\d{4,}$/),
]);
The number is the row's seq, zero-padded to 4. Scope of an id = id without -e\d+, -l\d+,
-r\d+; rule ids keep -\d{4,}, so the forms never collide. Source and attachment refs carry no
scope; every tool that accepts them also takes scope (D61).
3. Migration src/db/migrations/scope/0002-graph.ts#
One transaction, meta.schema_version = 2, foreign_keys = ON, legacy_alter_table off (default).
3.1 Log rebuild#
CREATE TABLE search_log_new ( /* columns exactly as 0001 */ ...,
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',
'resolve_entity','read_entity','create_entity','move_entity','update_entity','propose_link','list_links',
'list_structure','review_structure','archive_entity','export')), ...);
INSERT INTO search_log_new (id, at, grant_id, channel, operation, outcome, cross_scope, refs, terms, variants, stages, input_sha256)
SELECT id, at, grant_id, channel, operation, outcome, cross_scope, refs, terms, variants, stages, input_sha256 FROM search_log;
CREATE TABLE feedback_new ( /* as 0001 */ ..., search_id TEXT NOT NULL REFERENCES search_log_new(id), ...);
INSERT INTO feedback_new (id, search_id, ref, verdict, given_by, given_at) SELECT id, search_id, ref, verdict, given_by, given_at FROM feedback;
DROP TABLE feedback; -- child first, so no FK sees a missing parent
DROP TABLE search_log;
ALTER TABLE search_log_new RENAME TO search_log; -- rewrites feedback_new's REFERENCES to search_log
ALTER TABLE feedback_new RENAME TO feedback; -- sqlite_sequence follows the rename
Rights (auth/scope-check.ts OPERATION_RIGHT): resolve_entity, read_entity, list_links,
list_structure → read; create_entity, move_entity, update_entity, propose_link → propose;
review_structure, archive_entity → decide.
3.2 Entities#
CREATE TABLE entity ( -- head, immutable
seq INTEGER PRIMARY KEY, -- explicit max+1
id TEXT NOT NULL UNIQUE, -- <scope>-e<nnnn>, never reused
scope TEXT NOT NULL, -- contract `area`
created_at TEXT NOT NULL CHECK (created_at GLOB '????-??-??T??:??:??*Z'),
created_by TEXT NOT NULL CHECK (created_by IN ('human','agent','import'))); -- channel approval | mcp | api
CREATE TRIGGER entity_in_scope BEFORE INSERT ON entity
WHEN NEW.scope IS NOT (SELECT scope FROM meta) OR NEW.id IS NOT NEW.scope || '-e' || printf('%04d', NEW.seq)
BEGIN SELECT RAISE(ABORT, 'entity-id-not-in-scope'); END;
CREATE TABLE entity_version (
id TEXT NOT NULL REFERENCES entity(id),
version INTEGER NOT NULL CHECK (version >= 1),
type TEXT NOT NULL CHECK (length(type) BETWEEN 2 AND 30 AND type NOT GLOB '*[^a-z0-9_]*'), -- vocabulary from config
name TEXT NOT NULL CHECK (length(trim(name)) BETWEEN 1 AND 200),
aliases TEXT NOT NULL CHECK (json_valid(aliases) AND json_type(aliases) = 'array' AND json_array_length(aliases) <= 20),
parent_id TEXT REFERENCES entity(id),
status TEXT NOT NULL CHECK (status IN ('active','unconfirmed','archived')),
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_to TEXT CHECK (valid_to IS NULL OR valid_to GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'),
evidence TEXT CHECK (evidence IS NULL OR length(evidence) BETWEEN 1 AND 1000), -- ref (source/…, URL) or short text
change TEXT NOT NULL CHECK (change IN ('create','move','update','confirm','revert','archive')),
channel TEXT NOT NULL CHECK (channel IN ('mcp','api','approval')),
changed_by TEXT NOT NULL, -- grant id
changed_at TEXT NOT NULL CHECK (changed_at GLOB '????-??-??T??:??:??*Z'),
reason TEXT NOT NULL CHECK (length(trim(reason)) > 0),
PRIMARY KEY (id, version),
CHECK (parent_id IS NULL OR parent_id <> id),
CHECK (valid_to IS NULL OR valid_to >= valid_from),
CHECK ((status = 'archived') = (valid_to IS NOT NULL)),
CHECK ((channel = 'approval') = (change IN ('confirm','revert','archive'))), -- G7: agents never confirm, revert, archive
CHECK (channel <> 'approval' OR changed_by GLOB 'person:*'),
CHECK (change <> 'create' OR status = 'unconfirmed'),
CHECK (change <> 'confirm' OR status = 'active'),
CHECK (change <> 'archive' OR status = 'archived'));
CREATE VIEW entity_current AS SELECT v.* FROM entity_version v
WHERE v.version = (SELECT max(w.version) FROM entity_version w WHERE w.id = v.id);
CREATE TABLE entity_closure ( -- derived; written only by src/graph/closure.ts (AT5)
ancestor_id TEXT NOT NULL REFERENCES entity(id),
descendant_id TEXT NOT NULL REFERENCES entity(id),
depth INTEGER NOT NULL CHECK (depth >= 0),
PRIMARY KEY (ancestor_id, descendant_id),
CHECK ((depth = 0) = (ancestor_id = descendant_id))) WITHOUT ROWID;
CREATE INDEX entity_closure_up ON entity_closure(descendant_id, depth);
CREATE INDEX entity_closure_down ON entity_closure(ancestor_id, depth);
CREATE TRIGGER entity_version_rules BEFORE INSERT ON entity_version BEGIN
SELECT RAISE(ABORT, 'version-gap') WHERE NEW.version <>
1 + coalesce((SELECT max(version) FROM entity_version WHERE id = NEW.id), 0);
SELECT RAISE(ABORT, 'invalid-transition') WHERE (NEW.version = 1) <> (NEW.change = 'create');
SELECT RAISE(ABORT, 'terminal-state') WHERE
(SELECT status FROM entity_version WHERE id = NEW.id AND version = NEW.version - 1) = 'archived';
SELECT RAISE(ABORT, 'invalid-transition') WHERE NEW.change = 'confirm' AND
(SELECT status FROM entity_version WHERE id = NEW.id AND version = NEW.version - 1) <> 'unconfirmed';
SELECT RAISE(ABORT, 'invalid-transition') WHERE NEW.change IN ('move','update') AND NEW.status IS NOT
(SELECT status FROM entity_version WHERE id = NEW.id AND version = NEW.version - 1); -- agents never change status
SELECT RAISE(ABORT, 'state-changed') WHERE NEW.change IN ('confirm','archive') AND EXISTS (
SELECT 1 FROM entity_version p WHERE p.id = NEW.id AND p.version = NEW.version - 1 AND (p.type IS NOT NEW.type
OR p.name IS NOT NEW.name OR p.aliases IS NOT NEW.aliases OR p.parent_id IS NOT NEW.parent_id));
SELECT RAISE(ABORT, 'cycle') WHERE NEW.parent_id IS NOT NULL AND EXISTS ( -- G2: parent is self or a descendant
SELECT 1 FROM entity_closure WHERE ancestor_id = NEW.id AND descendant_id = NEW.parent_id);
SELECT RAISE(ABORT, 'parent-archived') WHERE NEW.status <> 'archived' AND NEW.parent_id IS NOT NULL
AND (SELECT status FROM entity_current WHERE id = NEW.parent_id) = 'archived';
SELECT RAISE(ABORT, 'has-children') WHERE NEW.status = 'archived' AND EXISTS ( -- G3: cascade archives bottom-up
SELECT 1 FROM entity_closure c JOIN entity_current k ON k.id = c.descendant_id
WHERE c.ancestor_id = NEW.id AND c.depth = 1 AND k.status <> 'archived');
END;
CREATE VIRTUAL TABLE entity_fts USING fts5(name, aliases, tokenize = 'trigram');
-- rowid = entity.seq; normalized text (§5.1) of the current version; written only by src/graph/fts.ts
Closure maintenance (graph/closure.ts, after the version insert, same transaction):
create → INSERT (X, X, 0) and INSERT SELECT ancestor_id, X, depth + 1 FROM entity_closure WHERE descendant_id = :parent; move →
DELETE FROM entity_closure
WHERE descendant_id IN (SELECT descendant_id FROM entity_closure WHERE ancestor_id = :x)
AND ancestor_id NOT IN (SELECT descendant_id FROM entity_closure WHERE ancestor_id = :x);
INSERT INTO entity_closure (ancestor_id, descendant_id, depth)
SELECT p.ancestor_id, s.descendant_id, p.depth + s.depth + 1
FROM entity_closure p JOIN entity_closure s ON s.ancestor_id = :x
WHERE p.descendant_id = :new_parent; -- nothing for a move to the top level
rebuildClosure(db) recomputes it from entity_current.parent_id with WITH RECURSIVE; tests
compare both after every move.
3.3 Links and reviews#
CREATE TABLE link ( -- stored links only (K2)
seq INTEGER PRIMARY KEY,
id TEXT NOT NULL UNIQUE, -- <scope>-l<nnnn>
from_ref TEXT NOT NULL, to_ref TEXT NOT NULL, -- Endpoint (§2)
type TEXT NOT NULL CHECK (length(type) BETWEEN 3 AND 40 AND type NOT GLOB '*[^a-z_]*'), -- vocabulary in code (§9.3)
origin TEXT NOT NULL CHECK (origin IN ('deterministic','proposed','human')),
confidence REAL CHECK (confidence IS NULL OR (confidence >= 0 AND confidence <= 1)),
reason TEXT CHECK (reason IS NULL OR length(trim(reason)) > 0),
evidence TEXT CHECK (evidence IS NULL OR length(evidence) BETWEEN 1 AND 1000),
valid_from TEXT NOT NULL CHECK (valid_from GLOB '????-??-??T??:??:??*Z'),
valid_to TEXT CHECK (valid_to IS NULL OR valid_to GLOB '????-??-??T??:??:??*Z'),
created_at TEXT NOT NULL CHECK (created_at GLOB '????-??-??T??:??:??*Z'),
created_by TEXT NOT NULL, -- 'code' for deterministic, else grant id
CHECK (from_ref <> to_ref),
CHECK ((origin = 'proposed') = (confidence IS NOT NULL)), -- G4
CHECK (origin = 'deterministic' OR reason IS NOT NULL),
CHECK ((origin = 'deterministic') = (created_by = 'code')),
CHECK (valid_to IS NULL OR valid_to >= valid_from),
CHECK (substr(from_ref, 1, 5) = 'rule/' OR substr(from_ref, 1, 7) IN ('source/','entity/') OR substr(from_ref, 1, 11) = 'attachment/'),
CHECK (substr(to_ref, 1, 5) = 'rule/' OR substr(to_ref, 1, 7) IN ('source/','entity/') OR substr(to_ref, 1, 11) = 'attachment/'));
CREATE UNIQUE INDEX link_open ON link(from_ref, type, to_ref) WHERE valid_to IS NULL;
CREATE INDEX link_to ON link(to_ref, type);
CREATE TRIGGER link_in_scope BEFORE INSERT ON link
WHEN NEW.id IS NOT (SELECT scope FROM meta) || '-l' || printf('%04d', NEW.seq)
BEGIN SELECT RAISE(ABORT, 'link-id-not-in-scope'); END;
CREATE TRIGGER link_endpoints BEFORE INSERT ON link BEGIN -- G1: both ends in THIS file
SELECT RAISE(ABORT, 'link-endpoint-unknown') WHERE NOT (
EXISTS (SELECT 1 FROM rule WHERE 'rule/' || id = NEW.from_ref)
OR EXISTS (SELECT 1 FROM entity WHERE 'entity/' || id = NEW.from_ref)
OR EXISTS (SELECT 1 FROM source WHERE 'source/' || id = NEW.from_ref)
OR EXISTS (SELECT 1 FROM source_attachment WHERE 'attachment/' || source_id || '#' || n = NEW.from_ref));
SELECT RAISE(ABORT, 'link-endpoint-unknown') WHERE NOT ( /* same four for NEW.to_ref */ );
SELECT RAISE(ABORT, 'entity-archived') WHERE EXISTS (SELECT 1 FROM entity_current
WHERE status = 'archived' AND 'entity/' || id IN (NEW.from_ref, NEW.to_ref));
END;
CREATE TRIGGER link_end_once BEFORE UPDATE ON link WHEN NOT (OLD.valid_to IS NULL AND NEW.valid_to IS NOT NULL
AND NEW.seq IS OLD.seq AND NEW.id IS OLD.id AND NEW.from_ref IS OLD.from_ref AND NEW.to_ref IS OLD.to_ref
AND NEW.type IS OLD.type AND NEW.origin IS OLD.origin AND NEW.confidence IS OLD.confidence
AND NEW.reason IS OLD.reason AND NEW.evidence IS OLD.evidence AND NEW.valid_from IS OLD.valid_from
AND NEW.created_at IS OLD.created_at AND NEW.created_by IS OLD.created_by)
BEGIN SELECT RAISE(ABORT, 'append-only'); END; -- contract: valid_to set once; no route sets it in this phase
CREATE TABLE review ( -- person verdicts on agent versions and proposed links
seq INTEGER PRIMARY KEY,
id TEXT NOT NULL UNIQUE, -- <scope>-r<nnnn>; same id-in-scope trigger as link
target TEXT NOT NULL, -- entity/<id>@<n> | link/<id>
verdict TEXT NOT NULL CHECK (verdict IN ('confirm','reject','revert')),
decided_by TEXT NOT NULL CHECK (decided_by GLOB 'person:*'), -- API field `by`
decided_by_identity TEXT NOT NULL, -- email, approval UI only (as D14)
decided_at TEXT NOT NULL CHECK (decided_at GLOB '????-??-??T??:??:??*Z'), -- API field `at`
reason TEXT,
new_version INTEGER, -- entity version written by this review, else NULL
CHECK (target GLOB 'entity/*@*' OR target GLOB 'link/*'),
CHECK (verdict <> 'reject' OR target GLOB 'link/*'),
CHECK (verdict <> 'revert' OR target GLOB 'entity/*'),
CHECK (verdict = 'confirm' OR length(trim(coalesce(reason, ''))) > 0));
CREATE UNIQUE INDEX review_once ON review(target); -- one verdict per entity version or link
-- append-only pairs (BEFORE UPDATE / BEFORE DELETE → RAISE(ABORT, 'append-only')) on entity,
-- entity_version, review; on link only BEFORE DELETE (updates go through link_end_once).
4. Writers and checks in code#
| Writer | Writes | Literal guarded by AT3 |
|---|---|---|
src/graph/entities.ts |
entity, entity_version with channel mcp/api, change create/move/update |
'unconfirmed' in the create insert |
src/graph/closure.ts · src/graph/fts.ts |
entity_closure · entity_fts |
— |
src/graph/links.ts |
link with origin proposed (proposeLink); and same_as, origin deterministic, created_by = 'code' (linkSameExternal, called by src/domain/sources.ts in the transaction that stores the attachment): a new attachment whose external (paperless:<n>) equals an earlier attachment's in the scope links to the earliest one |
'proposed', 'deterministic' |
src/structure/review.ts |
review, entity_version with channel = 'approval' |
— |
Create (entity_create, POST /v1/entities), in this order: type in graph.entity_types (else
400 validation-failed); parent exists in the scope (reference-unknown; parent of another scope →
scope-mismatch) and is not archived (parent-archived); depth of the new node ≤ graph.max_depth
(depth-limit); literal duplicate — same parent, same type, same name case-insensitively among
non-archived current versions → duplicate-candidate, force does not override; similar
duplicate — resolve (§5) with the name and each alias, same type, score ≥ 0.85 →
duplicate-candidate unless force: true with reason (reason-missing without). Then one
transaction: entity (seq = max + 1), version 1 (unconfirmed, valid_from = today in
config.zone, reason = given or "created via <channel>"), closure, FTS, search_log.
Move (entity_move): entity exists and is not archived; new parent as above; cycle if the
new parent is the entity or a descendant (checked in code first, so the answer carries both paths;
the trigger is the backstop); depth of the deepest moved node ≤ graph.max_depth; literal duplicate
under the new parent; same parent → answer moved: false, nothing written. New version copies the
current one with the new parent_id, change = 'move', status unchanged; closure for the subtree.
Update (entity_update): rename and add/remove aliases as one agent version (change = 'update',
status unchanged, pending until a review like a move); the new name and the added aliases pass the
same duplicate checks as a create, excluding the entity itself (force with reason overrides a
similar duplicate, never a literal one); removing an alias the entity lacks → 400
validation-failed; nothing changed → updated: false, nothing written.
Link (link_propose): both endpoints exist in the scope (reference-unknown), a rule endpoint
is not rejected (not-found, invariant 8), rule/entity ids of another scope → scope-mismatch, an
archived entity end → reference-unknown (remedy names it archived); type proposable (§9.3); an open link with the same (from_ref, type, to_ref) → answer it with
created: false (also when rejected: status: "rejected", never re-proposed).
Validity. link.valid_from = time of writing; bundles and lists use links open on the target day
(valid_from ≤ end of day, valid_to null or later).
5. entity_resolve algorithm (src/graph/normalize.ts, src/graph/resolve.ts)#
5.1 Normalization#
normalize(s) = NFKC → lowercase → ä→ae, ö→oe, ü→ue, ß→ss → NFD and drop \p{M} → every
character outside [\p{L}\p{N}] to a space → collapse spaces, trim → drop trailing legal-form
tokens (gmbh, mbh, ag, kg, ohg, ug, gbr, ev, se, co, inc, ltd, llc, plc) while ≥ 1 token remains.
entity_fts stores normalize(name) and the normalized aliases joined by |.
5.2 Recall#
key = normalize(q). Empty → 400validation-failed.- Exact pass (always): current, non-archived versions whose
normalize(name)or a normalized alias equalskey(loaded withjson_each; covers keys shorter than 3 characters such asvn). - Trigram pass (
length(key) ≥ 3):MATCHwith every trigram ofkeyas a quoted phrase, joined byOR(user text never reachesMATCHunquoted),ORDER BY rank LIMIT 50. - Initials pass (
keyis one token of 2–6 letters): names whose token initials equalkey.
5.3 Score (per entity: best over name and aliases; s = normalized string)#
| Match | Condition | Score |
|---|---|---|
exact / alias |
s = key (name / alias) |
1.00 |
tokens |
same token set, different order | 0.95 |
typo |
optimal-string-alignment distance d(key, s) = 1 with both ≥ 4 chars, or d ≤ 2 with both ≥ 9 chars, and the same digit runs in both (Filiale 12 / Filiale 13 and Level 1 / Level 2 are siblings, not typos), and the edits stay inside tokens: with as many tokens, every differing token pair (in order) meets the same distance rule itself (Division North / Division South and Team A / Team B differ in a whole token and are siblings; Vetrieb Nrod is a typo of Vertrieb Nord); with a different number of tokens, the strings without spaces differ by at most one edit (Vertriebnord / Vertrieb Nord) |
0.90 |
initials |
key = initials of s (s has ≥ 2 tokens) |
0.85 |
similar |
otherwise: 0.85 × Dice coefficient of the padded trigram sets | < 0.85 |
Candidates: score ≥ 0.50, optional type filter, archived excluded; order score desc, active
before unconfirmed, smaller depth, id; at most 5. Duplicate threshold for create/move: 0.85.
Examples (G2 tests): Mueller GmbH = Müller (exact); Vetrieb Nord → Vertrieb Nord (typo);
VN → alias VN (alias) or Vertrieb Nord without alias (initials); Nord Vertrieb (tokens);
Nord vs Nordwest below 0.85, so no duplicate refusal.
Bounds. name, every alias and q are limited to 200 characters both as given and after
normalization: NFKC expands some characters (one Arabic ligature becomes 18 letters), so 197 input
characters could otherwise be stored and scored as 3,546. A longer normalized form is refused by the
input schema on HTTP (400 validation-failed) and MCP alike; aliases_remove keeps only the raw
bound, since it is matched against stored aliases and never stored or scored. The distance is computed
only up to the typo limit 2: strings whose lengths differ by more than 2 are not compared, only the
diagonal band |i − j| ≤ 2 is filled, and the walk stops once a whole row exceeds 2, so one comparison
costs O(length) instead of O(length²).
6. Queries#
-- path (breadcrumb), root first
SELECT k.id, k.name, k.type FROM entity_closure c JOIN entity_current k ON k.id = c.ancestor_id
WHERE c.descendant_id = :id ORDER BY c.depth DESC;
-- direct children with counts, page of 50
SELECT k.id, k.name, k.type, k.status,
(SELECT count(*) FROM entity_closure g JOIN entity_current h ON h.id = g.descendant_id
WHERE g.ancestor_id = k.id AND g.depth = 1 AND h.status <> 'archived') AS children
FROM entity_closure c JOIN entity_current k ON k.id = c.descendant_id
WHERE c.ancestor_id = :id AND c.depth = 1 AND k.status <> 'archived'
ORDER BY lower(k.name), k.id LIMIT 50 OFFSET (:page - 1) * 50;
-- level counts below :id
SELECT c.depth, count(*) FROM entity_closure c JOIN entity_current k ON k.id = c.descendant_id
WHERE c.ancestor_id = :id AND c.depth BETWEEN 1 AND :depth AND k.status <> 'archived' GROUP BY c.depth;
Tree (entity_tree): level 1 = the page of direct children of id (top-level entities when
only scope is given); levels 2…depth add, node by node in order, up to 50 children each while the
answer holds fewer than 200 nodes. Every node carries children and hidden_children (children not
in this answer). depth default 1, maximum 3 (depth-limit above; the input schema accepts any depth ≥ 1 so the
answer can carry the remedy instead of an SDK validation error).
Everything about X (link_list with grouped: true on an entity): stored and computed links of
the entity and its descendants (descendant_id IN (SELECT descendant_id FROM entity_closure WHERE ancestor_id = :x)), other ends resolved to entries, grouped by kind like a bundle (§11),
newest first, counted per G5.
Computed links (never stored, id: null, origin: "deterministic"):
| Type | From → to | Column |
|---|---|---|
replies_to |
source/<mail> → source/<thread root> (only if the root is stored) |
source.thread |
attachment_of |
attachment/<s>#<n> → source/<s> |
source_attachment |
sourced_from |
rule/<id> → source/<source_id> |
rule_version.source_id |
supersedes |
rule/<new> → rule/<old> |
rule_version.supersedes / superseded_by of visible versions |
7. Configuration (groundnote.json, extends architecture.md §3; schema in src/graph/config.ts)#
const EntityType = z.string().regex(/^[a-z][a-z0-9_]{1,29}$/);
const DEFAULT_ENTITY_TYPES = ["organisation", "department", "project", "customer", "object", "person", "case", "topic"];
graph: z.object({
entity_types: z.array(EntityType).min(1).refine(unique).default(DEFAULT_ENTITY_TYPES), // the full list; extend by adding
max_depth: z.number().int().min(2).max(64).default(16), // levels incl. the top level
proposed_min_confidence: z.number().min(0).max(1).nullable().default(null), // null: only confirmed proposals count
}).strict().prefault({}), // Zod 4: prefault parses {} so the inner defaults apply
Removing a type from config keeps existing entities of it readable; new ones of that type fail. Tree limits (depth ≤ 3, pages of 50, 200 nodes) and resolve thresholds (0.50 / 0.85, 5 candidates) are contract constants, not config.
8. Guidance envelope and errors#
Refusals of the scope check on a graph tool (scope-not-allowed, channel-not-allowed, …) use the
same envelope {error, remedy, next}; a foreign, missing or in-graph foreign scope gives the same
body, so the answer never says whether a scope or entity exists (src/mcp/result.ts).
const PathItem = z.object({ id: EntityId, name: z.string(), type: z.string() }); // full, from the top
const Next = z.object({ tool: z.string(), why: z.string() }); // tool or prompt name
const Guidance = z.object({ path: z.array(PathItem), next: z.array(Next).max(3) }); // in every graph answer
const EntityOut = z.object({
ref: EntityRef, id: EntityId, scope: ScopeName, type: z.string(), name: z.string(), aliases: z.array(z.string()),
status: z.enum(["active", "unconfirmed", "archived"]), parent_id: EntityId.nullable(),
valid_from: Day, valid_to: Day.nullable(), children: z.number().int(),
pending: z.boolean(), // an agent version without review (§12)
approval_url: z.string().url().nullable(), // <public_url>/approval/structure?scope=<scope>#<id> when pending
});
const Candidate = z.object({ id: EntityId, name: z.string(), type: z.string(), status: EntityOut.shape.status,
aliases: z.array(z.string()), path: z.array(z.string()), score: z.number(),
match: z.enum(["exact", "alias", "tokens", "typo", "initials", "similar"]) });
const GraphErrorBody = ErrorBody.extend({ // `error` as in architecture.md §6.6
remedy: z.string(), next: z.array(Next).max(3),
candidates: z.array(Candidate).max(5).optional(), // duplicate-candidate
paths: z.object({ entity: z.array(PathItem), target: z.array(PathItem) }).optional(), // cycle
limit: z.number().int().optional() }); // depth-limit
path is the path of the entity the answer is about (for links: of the entity end, else []).
Errors raised inside graph tools are isError: true results with this body; HTTP uses the status
below. Schema rejections by the MCP SDK stay "Input validation error" (D45); the allowed type
values are therefore an enum in each tool's input schema (built per request from config).
| HTTP | error |
When | remedy (verbatim, <…> filled in) |
|---|---|---|---|
| 409 | duplicate-candidate |
create/move finds a literal or similar duplicate | "An entity like this exists: see candidates and their paths. Use one of them. Only if the person confirms the new one is different, call entity_create again with force: true and their reason. A literal duplicate under the same parent cannot be forced." |
| 409 | cycle |
new parent is the entity or inside its subtree | "<target> lies inside the subtree of <entity> (both paths are in paths). Choose a parent outside that subtree, or move <target> out first." |
| 422 | scope-mismatch |
parent, link end or entity of another scope | "Entities and links stay inside one scope. Use an entity of scope <scope> (entity_resolve with scope <scope>), or do the work in the other scope." |
| 400 | depth-limit |
tree depth > 3, or hierarchy deeper than graph.max_depth |
"Ask for depth ≤ 3 and call entity_tree again with id = a node whose hidden_children > 0." / "The tree may have at most <limit> levels. Attach it higher or flatten a level." |
| 409 | has-children |
archive (or revert of a create) without cascade while children live |
"Move or archive the children first, or archive with cascade." (approval only) |
| 409 | parent-unconfirmed |
confirm below an unconfirmed parent | "Confirm the parent first." (approval only) |
| 422 | parent-archived |
parent is archived | "Pick an active parent; archived entities take no new children." |
| 422 | reference-unknown |
parent, evidence ref or link end not in the scope, or archived | "<ref> does not exist in scope <scope>. Resolve it with entity_resolve; sources must be stored by a feeder first (POST /v1/sources)." |
| 400 | reason-missing |
force or revert/reject/archive without reason |
"Give a reason in one sentence: why the new entity differs / why this is undone." |
| 404 | not-found |
entity/link id unknown in an allowed scope; rejected rule | "Call entity_resolve with the name to find the current id." |
| 409 | version-outdated · already-decided |
review of an older version / second review (approval) | as architecture.md §6.4 |
next rules (code, ≤ 3, in this order): resolve exact/alias → entity_path ("check the place"),
link_propose ("file the item there"); candidates without exact → entity_path of the best,
entity_create ("only if the person says none is meant"); none → entity_tree ("find the parent"),
entity_create; create/move done → link_propose, entity_tree of the parent; tree with hidden
children → entity_tree on the first node with hidden_children > 0, or the next page;
duplicate-candidate → entity_path of the first candidate; cycle → entity_path of the target.
9. MCP (src/mcp/instructions.ts, src/mcp/prompts.ts, src/mcp/tools/entity-*.ts, link-*.ts)#
new McpServer({name: "groundnote", version}, {instructions: INSTRUCTIONS}). tools/list returns
the seven core tools (with rule_retract, approval-modes.md §8) and the eight graph tools; prompts/list the five prompts of §9.4.
9.1 Server instructions (verbatim)#
Groundnote keeps confirmed business rules with their evidence and, per scope, a small knowledge graph: entities (organisations, departments, projects, customers, objects, people, cases, topics) in one tree, and links between rules, sources, attachments and entities. Always name the scope; links exist only inside one scope. Call entity_resolve before entity_create, and whenever a name from a mail or conversation should be matched to an entity. Every graph answer contains the full path of the entity: tell the person the path. What you create or move takes effect at once but is unconfirmed until a person confirms it on the approval page: say so and give the approval_url. You never confirm, archive or revert structure, and you never confirm rules: point to the approval page. Read large trees step by step with entity_tree; never ask for a whole tree. Follow
nextwhen unsure; on an error followremedyinstead of repeating the call. For every rule you use, give its date and source, and pass on "still valid?". For standard tasks use the prompts create_level, file_incoming_mail, assign_document, restructure and where_does_this_belong. A rule marked auto-adopted was adopted by the policy without a person: say "auto-adopted" when you cite it. When the person asks, you may retract an auto-adopted rule with rule_retract; you never confirm a rule.
The last two sentences come from approval-modes.md §9 (slice A).
9.2 Tools#
| Tool | Input (Zod) | Output | Right · operation |
|---|---|---|---|
entity_resolve |
{scope, q: string 1–200, type?: EntityType} |
Guidance & {query: {q, normalized}, candidates: Candidate[]} (path of the best candidate) |
read · resolve_entity |
entity_create |
{scope, type, name: 1–200, aliases: string[] ≤ 20 = [], parent_id: EntityId | null, evidence?: string ≤ 1000, force: boolean = false, reason?: string 1–1000} |
Guidance & {entity: EntityOut, overridden: Candidate[]} |
propose · create_entity |
entity_move |
{id: EntityId, parent_id: EntityId | null, reason: string 1–1000} |
Guidance & {moved: boolean, entity: EntityOut, previous_path: PathItem[], subtree_size: int} |
propose · move_entity |
entity_tree |
{id?: EntityId, scope?: ScopeName, depth: int 1–3 = 1, page: int 1–10 000 = 1} (exactly one of id, scope) |
Guidance & {root: EntityOut | null, levels: [{depth, total, shown}], nodes: [{id, name, type, status, parent_id, depth, children, hidden_children}], page, pages} |
read · read_entity |
entity_update |
{id: EntityId, name?: 1–200, aliases_add?: string[] ≤ 20, aliases_remove?: string[] ≤ 20, reason: string 1–1000, force: boolean = false} |
Guidance & {updated: boolean, entity: EntityOut, overridden: Candidate[]} |
propose · update_entity |
entity_path |
{id: EntityId} |
Guidance & {entity: EntityOut, versions: [{version, change, parent_path: string[], status, channel, changed_at, reason, reviewed: boolean}]} |
read · read_entity |
link_propose |
{scope, from_ref: Endpoint, to_ref: Endpoint, type: ProposableType, confidence: number 0–1, reason: string 10–1000, evidence?: string ≤ 1000} |
Guidance & {created: boolean, link: LinkOut, counts_in_bundles: boolean, proposed_min_confidence?: number | null} (the threshold in force, present only when counts_in_bundles is false) |
propose · propose_link |
link_list |
{scope, ref: Endpoint, type?: LinkType, grouped: boolean = false, page: int 1–10 000 = 1} |
Guidance & {links: LinkOut[]} or, grouped, Guidance & {groups: Group[]} (§11 entries), 50 per page |
read · list_links |
const ProposableType = z.enum(["concerns", "belongs_to", "contradicts", "mentions", "same_as"]);
const LinkType = z.enum([...ProposableType.options, "replies_to", "attachment_of", "sourced_from", "supersedes"]);
const LinkOut = z.object({ id: LinkId.nullable(), from_ref: z.string(), to_ref: z.string(), type: LinkType,
origin: z.enum(["deterministic", "proposed", "human"]), confidence: z.number().nullable(),
status: z.enum(["confirmed", "proposed", "rejected"]), // deterministic/human: confirmed; proposed: by review
reason: z.string().nullable(), evidence: z.string().nullable(), valid_from: Instant, valid_to: Instant.nullable(),
created_at: Instant.nullable() }); // null for computed links
Descriptions (verbatim, the description of each registerTool):
- entity_resolve — "Find existing entities by name, alias or misspelling in one scope. Call it before every entity_create and whenever a name from a mail, document or conversation should be matched to an entity. Returns at most five candidates with score, how they matched and their full path. An exact or alias match means: use it. Several close candidates: show their paths and ask the person."
- entity_create — "Create one entity under a parent found with entity_resolve (parent_id null only for a new top level). Use it only when entity_resolve found nothing that fits. It is usable at once, with status unconfirmed, until a person confirms it on the approval page. It checks for duplicates itself: if a similar entity exists it creates nothing and answers duplicate-candidate with the candidates; use force: true with a reason only when the person says the new one is different."
- entity_move — "Move an entity with its whole subtree under another parent (parent_id null: top level), for example a department into another division. Takes effect at once as a new version; the old version stays in the history, and the move is unconfirmed until a person confirms or reverts it on the approval page. Show the person the old and the new path first. Refused with cycle if the new parent lies inside the moved subtree."
- entity_update — "Rename an entity or add and remove its aliases, for example a short name or a spelling used in mails. Takes effect at once as a new version; the old version stays in the history, and the change is unconfirmed until a person confirms or reverts it on the approval page. The new name and added aliases are checked for duplicates like entity_create: on duplicate-candidate use force: true with a reason only when the person says the entity is different."
- entity_tree — "Show the entities below one entity (or the top level of a scope): direct children by default, at most depth 3, 50 per page. Every node says how many children it has and how many are hidden; expand a node by calling again with its id. Use it step by step to get an overview or to find a parent; never page through a whole tree unasked."
- entity_path — "Return the full path of one entity from the top level down, its status and its versions including moves. Use it when only the place of an entity is asked, and to check a candidate before filing something under it."
- link_propose — "Propose a link inside one scope between a rule, source, attachment or entity, for example a mail concerns a project or a document belongs to an object. Give an honest confidence from 0 to 1 and a reason that names the evidence. A proposed link counts in search bundles only once a person confirms it or its confidence reaches the configured minimum, and it is always marked as proposed. Thread, attachment, source and supersedes links exist already; do not propose them."
- link_list — "List the links of one item: stored and derived ones, proposed ones with their confidence. With grouped: true on an entity it returns everything about the entity and its descendants, grouped by kind with dates. 50 per page."
9.3 Link vocabulary#
| Type | Typical ends | Writer |
|---|---|---|
concerns |
rule/source/attachment → entity (G8: resolved rule relations) | agent (proposed) |
belongs_to |
source/attachment → entity (mail or document filed under a case, project, object) | agent |
mentions |
source/attachment → entity (named, not about it) | agent |
contradicts |
rule ↔ rule (listed both ways) | agent |
same_as |
attachment ↔ attachment (same Paperless id: code); entity ↔ entity (agent, for merging by a person) | code · agent |
replies_to, attachment_of, sourced_from, supersedes |
§6, computed | column |
9.4 Prompts (server.registerPrompt; arguments are strings)#
Arguments are checked before any text is built: scope is a ScopeName, source a mail or jamie
id, ref an Endpoint; names ≤ 200 characters, subject, sender, hint, reason and
evidence ≤ 1000, description ≤ 2000. Every argument except scope and type is foreign
text — name, parent, evidence, entity, new_parent, subject, sender, hint, reason,
description, and the refs source and ref: the message names each by its label ("the subject
quoted below", "the document ref quoted below") and appends it in its own untrusted block with one
boundary, followed by the untrusted-text preamble (F4, src/mcp/prompts.ts). The {…} of these
arguments in the table below stand for that reference. Every prompt has at least one required
foreign argument and therefore always carries the preamble.
| Name | Arguments (required*) | Message text (one user message; {…} filled in, optional parts omitted when empty) |
|---|---|---|
create_level |
scope*, name*, type*, parent, evidence |
"Create the {type} "{name}" in scope {scope}{ under "{parent}"}. 1. Find the parent: call entity_resolve for "{parent}" (without a parent, ask the person and call entity_tree with scope {scope}); confirm its path with the person. 2. Call entity_resolve for "{name}" with type {type}; if a candidate matches exactly, by alias or by typo, show its path and ask whether it is meant; if yes, stop. 3. Call entity_create with parent_id, type, name and evidence ({evidence} or the person's words). On duplicate-candidate show the candidates' paths; use force with a reason only if the person says it is different. 4. For a new level with several children (for example a head of division with seven departments) create the parent first, then each child under the returned id; create nothing the person did not name. 5. Tell the person the full path, that it is unconfirmed until a person confirms it, and the approval_url." |
file_incoming_mail |
scope*, source* (mail:<…>), subject, sender |
"File the incoming mail {source} in scope {scope} (subject "{subject}", from {sender}); use the mail text you have. 1. Call search_rules with scope {scope} and text = subject plus the key sentences; cite each bundle with date and source. 2. Take the names the mail is about (the sender's organisation, customer, project, object, case number) and call entity_resolve for each, at most five. 3. For each entity that fits (exact, alias or typo, else ask), call link_propose from source/{source} to entity/<id> with type belongs_to (concerns if the mail is only about it), an honest confidence and a reason quoting the header or sentence. 4. Do not create entities here; for a missing important name offer create_level. 5. Report each link with path, confidence and counts_in_bundles, and the bundles found." |
assign_document |
scope*, ref* (attachment/… or source/…), hint |
"Assign the document {ref} in scope {scope} to the place it belongs{; hint: {hint}}. 1. If the document is the source or an attachment of a rule, read it with provenance for that rule; otherwise use the file name and text you have. 2. Call entity_resolve for its most specific names (object, customer, project, case number), most specific first. 3. One clear candidate: confirm with entity_path; several: show at most three paths and ask. 4. Call link_propose from {ref} to the entity with type belongs_to, confidence and a reason naming the evidence. 5. Nothing fits: run where_does_this_belong instead of creating an entity." |
restructure |
scope*, entity*, new_parent* (top for the top level), reason |
"Move "{entity}" under "{new_parent}" in scope {scope}. 1. Resolve both with entity_resolve and call entity_path for both; ask if either is ambiguous. 2. Call entity_tree on the target to see its children, and on the entity to learn its subtree. 3. Show the person old path → new path and how many entities move along; get agreement and the reason{ (given: {reason})}. 4. Call entity_move with the reason. On cycle, explain that the target lies inside the moved subtree and ask for another parent. 5. Tell the person the move is in effect, kept in the history and unconfirmed until a person confirms or reverts it on the approval page." |
where_does_this_belong |
scope*, description*, ref |
"Find where this belongs in scope {scope}: {description}{ (item {ref})}. 1. Call entity_resolve for up to five distinctive names or numbers from the description. 2. Without a candidate of score ≥ 0.5, call entity_tree with scope {scope}, choose the most plausible entity and go one level deeper at most three times. 3. Offer at most three places, each as a full path with one sentence why, and ask the person. Do not create anything while it is ambiguous. 4. When the person chooses: with an item, call link_propose from {ref} to the chosen entity (type belongs_to); if the place does not exist, continue with create_level." |
10. HTTP routes (contract H; same domain functions, channel api unless noted)#
| Method | Path | Right | Body / query → response |
|---|---|---|---|
| GET | /v1/entities/resolve?scope=&q=&type= |
read | as entity_resolve |
| GET | /v1/entities?scope=&page= |
read | top level, as entity_tree with scope (D61) |
| POST | /v1/entities |
propose | entity_create input → 201 |
| POST | /v1/entities/:id/move |
propose | {parent_id, reason} → 200 |
| GET | /v1/entities/:id/path · /v1/entities/:id/tree?depth=&page= |
read | as the tools |
| GET | /v1/links?scope=&ref=&type=&grouped=&page= |
read | as link_list |
| POST | /v1/links |
propose | link_propose input → 201 / 200 (created: false) |
| GET | /approval/structure?scope= |
read (approval) | §12 |
| POST | /v1/reviews |
decide (approval, CSRF) | ReviewRequest → 200 {review, target, status} / form: 303 to /approval/structure?scope= |
| POST | /v1/entities/:id/archive |
decide (approval, CSRF) | {version, reason, cascade = false} → 200 / form 303 |
/v1/reviews and /v1/entities/:id/archive belong to the approval channel (aud aud_approval,
session cookie in dev) although they sit under /v1/ — like the rule decision route. An entity
created via api gets created_by = 'import'.
const ReviewRequest = z.object({
target: z.union([EntityRef.refine(hasVersion), z.string().regex(/^link\/[a-z][a-z0-9-]{1,39}-l\d{4,}$/)]),
verdict: z.enum(["confirm", "reject", "revert"]),
reason: z.string().min(1).max(1000).optional(), // required for reject and revert (reason-missing)
}).strict();
11. Bundles (extends architecture.md §6.5 and §8)#
const EntryKind = z.enum(["rule", "note", "meeting", "mail", "attachment", "entity"]);
const Entry = /* as §6.5 */ .extend({
origin: z.enum(["deterministic", "proposed", "human"]), // of the link the entry hangs on
proposed: z.boolean() }); // proposed and not yet confirmed (G5)
const Head = /* as §6.5 */ .extend({
entities: z.array(z.object({ ref: z.string(), name: z.string(), type: z.string(),
path: z.array(z.object({ ref: z.string(), name: z.string(), type: z.string() })),
proposed: z.boolean() })).max(10) });
const Group = /* kind */ z.enum(["source", "thread", "attachment", "entity", "predecessor", "successor",
"contradicts", "same-relation"]); // fixed order, empty omitted
const SearchRequest = /* as §6.5 */ .extend({ entity: EntityId.optional() }); // one scope only (else 400)
- Counted links (G5): computed and
deterministic/humanlinks always;proposedlinks only if reviewedconfirm, orproposed_min_confidenceis not null, the confidence reaches it and norejectexists. Rejected never. Open on the target day (as_ofor today). entitygroup /head.entities: entities linked to the head rule (concerns,belongs_to,mentions), not archived; entryref entity/<id>,title= path joined with›,date= linkvalid_from,excerpt/linknull. Order: not proposed first, then confidence, then date.contradicts: rules linked to the head bycontradicts(either direction), passing the head filter.same-relationadditionally holds rules with a counted link to the same entity or one of its ancestors (G8, viaentity_closure).- Existing groups get
origin: "deterministic",proposed: false(computed links of §6). entityfilter (G8): heads with a counted link to the entity or any of its ancestors.- Compact MCP answer (architecture.md §6.5):
head.entitiesis left out; theentitygroup names the entities by ref andrelatedcarries each one's path astitle, once per answer. line: parts after<n> attachment(s):<n> entit(y|ies); aftersuperseded by <id>:<n> contradicting rule(s); when any shown entry is proposed, the parts end with(of which <p> proposed). Bundles without graph data are unchanged.
12. Approval: "Structure changes" (src/structure/, src/approval/)#
Pending = per entity, the agent versions (channel mcp/api) after its latest approval
version that have no review; plus every proposed link without a review. GET /approval shows a
section Structure changes per scope (counts, newest five, link to the full page).
GET /approval/structure?scope= lists every pending item newest first: entity with path before and
after (create: "new under …"; move: old → new path, subtree size), who (grant, channel), reason,
evidence, the duplicate candidates the create overrode, and for links both ends (entity path, rule
statement, source title) with type, confidence and reason; plus forms with target = the current
version, csrf, and (archive) version. The rule page (/approval/rules/:id) lists the rule's
pending concerns links with their own confirm/reject forms (G8).
| Verdict | On | Effect (one transaction; search_log operation review_structure) |
|---|---|---|
confirm |
entity/<id>@<current> |
a review row for every pending version of the entity; if the status is unconfirmed, a new version change = 'confirm', active; the parent must be active (else 409 parent-unconfirmed, remedy "Confirm the parent first.") |
revert |
entity/<id>@<current> |
a review row for every pending version; new version = state before the first pending one (change = 'revert'); if the first pending one is the create: archived with valid_to = today (409 has-children if live children remain). cycle/parent-archived if the old place is no longer valid. Closure, FTS |
confirm · reject |
link/<id> (proposed, unreviewed) |
review row; the link stays, a rejected one never counts |
| archive | POST /v1/entities/:id/archive |
new version archived, valid_to = today; cascade archives the subtree deepest first |
A review whose target is not the current version → 409 version-outdated (current: entity/<id>@<n>);
a second review → 409 already-decided. Pages escape all output; no client JavaScript (D34).
13. Architecture tests (extend test/arch/architecture.test.ts)#
- AT1 the import closure of
src/mcp/**contains no file ofsrc/decision/**orsrc/structure/**. - AT2b
src/structure/**is imported only bysrc/http/routes/structure.tsandsrc/approval/**. - AT3 additionally:
INSERT INTO entity_versiononly insrc/graph/entities.ts(create insert with literal'unconfirmed') andsrc/structure/**;INSERT INTO entity\bonly insrc/graph/entities.ts;INSERT INTO link\bonly insrc/graph/links.ts('proposed'and'deterministic');INSERT INTO review\bonly insrc/structure/**. - AT4 the regex becomes
/confirm|reject|revoke|review|decide|approve|archive|revert/and applies to tool and prompt names. - AT5 statements writing
entity_closure/entity_fts(INSERT,UPDATE,DELETE) occur only insrc/graph/closure.ts/src/graph/fts.tsandsrc/db/migrations/**.
14. Slice plan#
Each slice ends green on pnpm lint && pnpm typecheck && pnpm test and is one commit.
| # | Slice | Acceptance tests |
|---|---|---|
| G1 | 0002-graph, ids, config, loss count |
migrating a 0001 file with search_log + feedback rows keeps all rows and ids, PRAGMA foreign_key_check empty, feedback references search_log, new operations accepted; UPDATE/DELETE abort on entity, entity_version, review, DELETE on link, link_end_once; id-in-scope (entity, link, review); version gap, invalid transitions, terminal archived, confirm/archive changing state abort; CHECKs (agent cannot write active, approval needs person:*); cycle trigger (self, child, grandchild as parent); has-children, parent-archived; link CHECKs (proposed without confidence or reason, from = to, created_by vs origin), endpoint unknown, endpoint entity/other-e0001 unknown, archived end; open check counts all four tables (fewer → locked); config defaults, duplicate type, unknown key |
| G2 | graph/normalize, resolve, closure, tree, read side of links |
normalization table; resolve via alias, typo (Vetrieb), transposition, initials, token order, legal form, umlaut spelling; Nord/Nordwest no duplicate; archived excluded; ≤ 5 ordered; depth ≥ 6 and ≥ 7 siblings: organisation › head of division › 7 departments › team › project › customer › object — path root-first, tree depth 1 shows 7 children, depth 3 with level counts, hidden_children on cut nodes, page 2 of 60 children, depth 4 → depth-limit; closure equals rebuildClosure after 200 random moves; computed links and grouped "everything about X" over a subtree; rejected rules never listed |
| G3 | Writes: graph/entities, links, domain/sources same_as |
create unconfirmed, created_by per channel; duplicates via aliases/typos → duplicate-candidate with candidates and paths, nothing stored; force without reason → reason-missing; force with reason → created, overridden; literal duplicate not forceable; unknown type 400; max_depth → depth-limit; re-parent with history: move a department with 3 levels below to another division — new version, old version unchanged, entity_path versions show both paths, closure updated; cycle via code with both paths; scope boundary: parent or link end of another allowed scope → scope-mismatch, of a foreign scope → 403 scope-not-allowed; link idempotent, rejected link answered created: false; second attachment with the same Paperless id → one same_as link by code; one search_log row per call |
| G4 | MCP tools + instructions | initialize carries the instructions verbatim; tools/list = 14 names (15 since approval-modes.md slice A5) with the verbatim descriptions; every success has path and next (≤ 3); every graph error code of §8 comes back as isError with error, remedy, next; propose-only grant cannot resolve; AT4 extended |
| G5 | MCP prompts | prompts/list returns exactly the five names with their arguments and required flags; prompts/get fills the arguments and drops empty optional parts; missing required argument → error; prompt names pass AT4 |
| G6 | Bundles | bundle with path: head rule with a confirmed concerns link → head.entities[0].path root-first and entity group; proposed link below / above proposed_min_confidence (and with null) absent / present with proposed: true; rejected absent; contradicts group; same-relation via ancestor; entity filter finds a rule at customer Y for an object below Y; line with "(of which 1 proposed)"; slice-6 fixtures without graph data unchanged |
| G7 | src/structure/, review and archive routes, approval pages |
confirm single and with pending moves; parent-unconfirmed; revert of a move restores path and closure; revert of a create archives; revert into an archived parent → parent-archived; link confirm/reject; reasons required; review of an older version → version-outdated; second review → already-decided; archive with children → has-children, with cascade bottom-up; CSRF missing/wrong → 403; MCP JWT → 401; service grant → 403; "Structure changes" section and page escape content; AT1, AT2b, AT3, AT5 |
| G8 | HTTP routes (§10) | each route maps to the tool function; same codes with the HTTP status of §8; api create → created_by = 'import'; scope checks as 5.2 |
| G9 | E2E "head of division" + docs/agents.md (dokumentar) |
over MCP: resolve → create organisation, head of division, 7 departments, teams, project, customer, object; typo resolve finds the department; duplicate refused; move a department; person confirms on the structure page; feeder stores a mail; file_incoming_mail flow proposes belongs_to; person confirms; search_rules bundle shows the path. docs/agents.md holds the guide and example dialogues incl. this hierarchy; a test checks that every tool and prompt name it mentions exists |
15. Open questions#
Agents cannot add aliases or rename.Decided 2026-11-05:entity_updatenow (§4 "Update"), an agent change that stays pending until a person reviews it; its MCP description is in §9.2 (G4).- Archived is terminal; restoring means a new entity. Enough, or an "unarchive" verdict?
- Should a person create or move entities directly on the approval page (
created_by = 'human',activeat once)? No route exists in the contract, sohumanlinks and creates have no writer yet.