Seiten der Dokumentation

Diese Seite gibt es bisher nur auf Englisch.

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.

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#

  1. key = normalize(q). Empty → 400 validation-failed.
  2. Exact pass (always): current, non-archived versions whose normalize(name) or a normalized alias equals key (loaded with json_each; covers keys shorter than 3 characters such as vn).
  3. Trigram pass (length(key) ≥ 3): MATCH with every trigram of key as a quoted phrase, joined by OR (user text never reaches MATCH unquoted), ORDER BY rank LIMIT 50.
  4. Initials pass (key is one token of 2–6 letters): names whose token initials equal key.

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.

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 next when unsure; on an error follow remedy instead 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."
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/human links always; proposed links only if reviewed confirm, or proposed_min_confidence is not null, the confidence reaches it and no reject exists. Rejected never. Open on the target day (as_of or today).
  • entity group / head.entities: entities linked to the head rule (concerns, belongs_to, mentions), not archived; entry ref entity/<id>, title = path joined with ›, date = link valid_from, excerpt/link null. Order: not proposed first, then confidence, then date.
  • contradicts: rules linked to the head by contradicts (either direction), passing the head filter.
  • same-relation additionally holds rules with a counted link to the same entity or one of its ancestors (G8, via entity_closure).
  • Existing groups get origin: "deterministic", proposed: false (computed links of §6).
  • entity filter (G8): heads with a counted link to the entity or any of its ancestors.
  • Compact MCP answer (architecture.md §6.5): head.entities is left out; the entity group names the entities by ref and related carries each one's path as title, once per answer.
  • line: parts after <n> attachment(s): <n> entit(y|ies); after superseded 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 of src/decision/** or src/structure/**.
  • AT2b src/structure/** is imported only by src/http/routes/structure.ts and src/approval/**.
  • AT3 additionally: INSERT INTO entity_version only in src/graph/entities.ts (create insert with literal 'unconfirmed') and src/structure/**; INSERT INTO entity\b only in src/graph/entities.ts; INSERT INTO link\b only in src/graph/links.ts ('proposed' and 'deterministic'); INSERT INTO review\b only in src/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 in src/graph/closure.ts / src/graph/fts.ts and src/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#

  1. Agents cannot add aliases or rename. Decided 2026-11-05: entity_update now (§4 "Update"), an agent change that stays pending until a person reviews it; its MCP description is in §9.2 (G4).
  2. Archived is terminal; restoring means a new entity. Enough, or an "unarchive" verdict?
  3. Should a person create or move entities directly on the approval page (created_by = 'human', active at once)? No route exists in the contract, so human links and creates have no writer yet.

Auf GitHub bearbeiten