Seiten der Dokumentation

Diese Seite gibt es bisher nur auf Englisch.

Deviations from the contract (Abweichungen)#

Groundnote was specified in German ("Wissensdienst": datenmodell.md, api-vertrag.md, revised 2026-11-05). architecture.md is now the authoritative English contract. This file lists every place where it departs in substance from the German draft. Pure renames (German → English identifiers, routes, tables, tools, CLI, error codes) are not deviations; they are in glossary.md.

Each entry: what the German contract says → what Groundnote does → why.

Scope of Phase 1#

# Contract Groundnote Why
D1 Stage 1 of the roadmap includes nightly export, Litestream, daily restore probe and the test-set tool. Phase 1 builds the core loop only: sources, proposals, decisions, search stage a, MCP, approval UI, ops. Backup is validated in config, not executed; export, probe, test set follow in Phase 2. Prove the evidence → approval → retrieval loop first; backup execution is operator tooling around a stable schema.
D2 Webhooks (A), extraction (E), proactive outputs (D), 429 budget-reached. Not built. (Stages b and c and the vector-index rebuild are built, see D101; both stay off until enabled in config.) Phases 2–4 (architecture.md §12). A config that enables something which does not exist must fail at start, not silently do nothing.
D3 Tables inbox, extraction, notice_reported, test_case, test_run (scope file); feeder, outbound_log (ops file); backup with lag/export/probe columns. Not created in Phase 1. The ops file has backup_state with the version count and, since migration 0002, the result of the last backup verify; the lag, export and probe columns follow with their phase. No table without a writer. ai_usage (ops file, migration 0003, monthly spend of stage c, append-only) is built with stage c; its single writer is src/search/rerank/budget.ts.
D4 Config has feeders, outputs, models, budgets_month_usd, export_time. Not in the Phase 1 schema; the schema is strict, so these keys fail start until their phase adds them. Unknown keys are more likely typos than intent.

Sources (POST /v1/sources)#

# Contract Groundnote Why
D5 Answers 202 {accepted, id} and processes asynchronously. Stores synchronously: 201 on first call, 200 with created: false on a repeat; body {created, id} (D66). Phase 1 has no extraction behind it; the caller gets validation errors directly.
D6 Body carries text (mail); Jamie sources arrive only via webhook and are fetched with the feeder key. Also accepts source.kind: "jamie" with segments: [{start_s, speaker, text}]. Mail requires text, Jamie requires segments. Self-hosters can push transcripts without a webhook; the ±120 s quote check needs timed segments.
D7 The source stores an excerpt: ±120 s of transcript around the quote. Stores the segments as received in the new append-only table source_segment; the client decides how much it sends. Without extraction the quote is unknown when the source is stored.
D8 Attachment request has no text origin; size is a table column without nullability rule. Request requires text_origin: "ocr" | "description" when text is set (stored none otherwise); size may be null. The sender knows how the text came about; the service must not guess. Senders do not always know the size.
D9 capture and proactive flags in the body. Rejected by the strict schema in Phase 1. A flag without effect would claim behaviour that does not exist.
D10 feeder column names the feeder. Holds the grant id of the caller in Phase 1. Feeders are not configured before Phase 2.
D11 Unknown Jamie source in a proposal is fetched if the scope has a Jamie feeder. Always 422 source-unknown. No feeders in Phase 1.

Rules, proposals, decisions#

# Contract Groundnote Why
D12 Manual source id manual:<grant-id> is sent by the client. The service sets it from the caller's grant; the request for kind: "manual" has no id. Otherwise a client could file proposals under another grant's identity.
D13 Table rule holds an immutable kind; the decision change may alter kind. kind lives only in rule_version. One place per fact; an immutable copy would contradict a changed kind.
D14 Decision wer is "the identity from the JWT"; the export shows a grant id (person:2). decision stores both decided_by (grant id) and decided_by_identity (email). Read routes return only the grant id; the approval UI shows the email. The scope file stays self-contained about who decided, without handing emails to every reader.
D15 The change example lists statement, keywords, relations, valid_from, kind; the field-ownership table also lets the person change said_at, speaker, supersedes. change accepts all eight fields. Source, quote, timestamp and link stay immutable (trigger + 400 source-immutable). Resolves the inconsistency in favour of the ownership table; rule 3 only protects the evidence.
D16 revoke example carries valid_until; behaviour without it and for revoke/review on a proposal is not specified. valid_until is required for revoke; revoke/review on a proposal → 400 validation-failed. No silent "today" default; a proposal has never been valid.
D17 Rejected rules are removed from the search index, yet the approval page must show "previously rejected similar" proposals. Second FTS5 table rejected_fts, written only by src/decision/, read only through it by the approval UI (architecture test). The comparison needs full-text matching without putting rejected rules back where search can reach them.

Storage and locks#

# Contract Groundnote Why
D18 Rule 7 locks a scope when the file has zero versions but backup counted some. Locks when the file has fewer versions than backup_state remembers; also on failed quick_check, missing file of a created scope, or unknown schema_version. All answer 503 scope-locked. A restore from an older point serves stale decisions just as silently as an empty file; matches backup.md ("restore ops and scope files from the same time").
D19 search_log columns describe searches; step 5 of the scope check requires logging every access. search_log gets operation and outcome; reads, proposals, decisions, source storage and listings are logged too. Channel enum adds approval. Makes step 5 implementable without a second log table.
D20 Grants are created and disabled; nothing about later edits. Trigger: a grant row can only change from active to disabled; grants are never re-enabled or edited. An access change is a new grant, visible in history.
D101 Stage b keeps its vectors in a second file per scope, bereiche/<bereich>.vektor.sqlite (glossary: data/scopes/<scope>.vector.sqlite). Built. Vectors live in the scope file itself, table vec_rule (key seq + version; model id, dimension and text hash per row; float32 BLOB, brute-force cosine in JS, no sqlite-vec); derived data, rebuilt from rule_version by the background indexer or POST /ops/vector-index/:scope/rebuild (search/stages-b-c.md). A redaction removes rule text, FTS rows and vectors in one transaction and one VACUUM; one file to lock, back up, verify and restore, so a restore can never pair rules with vectors of another state. Cost: the scope file grows by about 4 KB per embedded rule.

Access and channels#

# Contract Groundnote Why
D21 Config access {issuer, jwks, aud_mcp, aud_approval}; each channel is its own Access app with its own AUD. access {team_domain, aud_mcp, aud_approval, aud_api}; JWKS = <team_domain>/cdn-cgi/access/certs, issuer = team domain. The issuer is Cloudflare Access (decided); the API channel needs its own AUD, which the draft did not provide.
D22 No public base URL in config. public_url is required. Needed for WWW-Authenticate resource_metadata, approval_url and the CSRF origin check.
D23 Only Cloudflare Access (plus service bearers). GROUNDNOTE_AUTH_MODE is required (cloudflare-access | dev-token); dev-token mode uses a dev MCP bearer and a login form for approval, refuses non-loopback hosts unless explicitly allowed, and refuses start in cloudflare-access mode if any dev variable is set. Local development without Cloudflare, without making it a production path.
D24 MCP is reached with "a person's JWT". Service identities on /mcp (and on approval) → 403 channel-not-allowed; a request carrying both a JWT and a bearer → 401. Makes the person-only rule and credential precedence explicit.
D25 Approval channel: Access login, person only. Additionally same-origin check (Origin / Sec-Fetch-Site) and a CSRF token (HMAC of the identity) on every state-changing POST; failure → 403 csrf-failed. The decision route also accepts form encoding. The approval channel is a browser form; Access cookies alone do not stop cross-site posts.
D26 Step 3: more than one scope only with cross_scope; no default. Explicit exception for grant-wide listings that name no scope: MCP scopes, GET /approval, GET /v1/status. The draft's scopes tool already lists all own scopes; the exception is now written down instead of implied.
D27 Ops routes /v1/ops/* "bound to 127.0.0.1" on the service. Separate listener on the Unix socket <data dir>/ops.sock (mode 0600, see D69) with routes /ops/* and its own bearer. One listener cannot be public and loopback-only at once; a separate listener makes ops structurally unreachable from outside.
D28 401 with resource_metadata observed in the connector test. GET /.well-known/oauth-protected-resource (RFC 9728) is served by the service; authorization_servers is omitted in dev-token mode. Clients discover the authorization server from it.
D68 No rate limit; 429 exists only as budget-reached for AI budgets. In-process token buckets (limits in groundnote.json): 60 requests/min per grant on /mcp and /v1, 10 decisions/min per grant, 5 dev logins/min per client address; over the limit 429 rate-limited with Retry-After. Limits the blast radius of a stolen token and guessing on the dev login (threat model F1); one process per instance needs no shared store.

Search and responses#

# Contract Groundnote Why
D29 Step 1 generates terms with the model models.terms (synonyms, inflection, compound parts). Terms come from code: tokenize, drop de/en stopwords; variants add configured synonym groups, de/en stemming, compound parts and typo corrections besides spelling variants and prefix (D70). No LLM in Phase 1; synonym groups and stemming stand in for model variants, deterministic and testable.
D30 Search accepts inbox (mail:<id> from the buffer). Not accepted in Phase 1 (text and/or terms). No inbox before Phase 4.
D31 line is German with 12.03.2026 dates. English, ISO dates (2026-03-12) in the instance zone; unread attachments are counted in the line. Repository language is English; ISO dates are unambiguous.
D32 /v1/status reports backup, extraction rate, missing count, stage costs, test-set run, feeders. Phase 1: per allowed scope counts per status and locked, the state of each stage as a search would meet it (b loading/failed by its model, c budget at the cap), and the checks of D99; stage costs only as budget, the amount stays with the operator (groundnote doctor). The other figures have no source before their phase; a service token reading the bill would learn the operator's usage.
D33 Error table names codes only for some statuses; 401/404 have none. Every error body has error: added validation-failed, unauthorized, not-found, csrf-failed, payload-too-large, internal, and ops codes scope-not-configured, scope-exists, scope-unknown. Clients branch on a code, not on prose.
D34 Where the approval page lives is open. Served by the service under /approval/*, server-rendered, no client JavaScript. Relations are edited via JSON on the decision route, not in the form. Smallest surface; one origin for CSRF and CSP.
D67 No relevance threshold for stage a (the contract tunes a threshold only for the later stages, on the scope's test set); an empty result carries no reason; feedback names a search id without tying it to a grant. Stage a has no threshold by default either, but every head carries coverage (IDF share of the query terms it matches) and matched_terms (section 7.6a). Only a configured search.min_coverage > 0 drops weaker heads; then an empty result says abstained {reason: below-min-coverage, best_coverage, min_coverage}, and every empty result says no-candidate when nothing matched. Feedback binds search_id to the grant that ran the search (another grant gets 404). A fixed stage-a threshold (concept coverage ≥ 0.5, built first) cost 38–50 % of the answerable queries in eval/lexical; the caller or stages b/c decide "nothing suitable" from coverage instead. Feedback on someone else's search would let a grant write into another grant's evaluation data.
D70 Stage a = one FTS index, prefix variants, rank.a = -bm25. Lexical stage a (src/search/lexical/, search.lexical): word, stem/compound, synonym and typo sub-indexes (lex_* tables, migration 0003), key terms for long texts, validity weighting, score fusion; rank.a = fusion value. All switches off reproduce the contract. Measured on real data: R@10 0.63 → 0.80, MRR 0.55 → 0.64; held-out MRR 0.66 → 0.80; p95 17–38 ms at 20,000 rules (docs/search/lexical.md).

As built: sources, proposals, decisions#

# Contract Groundnote Why
D35 A repeated source id adds missing attachments; nothing is said about differing content or other callers. A repeat is accepted only from the grant that stored the source (feeder = grant id) and only if the source fields and the already stored attachments are identical; otherwise 409 source-conflict with one generic message for every case; the reason (other-feeder, source-differs, attachments-differ) is written only to the process log. Tightens D5 and section 6.2. Sources are append-only; a second caller or a changed body must not silently extend or contradict stored evidence. One answer for all cases keeps a caller from probing which message ids another grant stored or what they contain.
D36 link is a URL. Only http: and https: links are accepted (400 validation-failed otherwise). The approval page renders the link; javascript: and data: URLs would be an injection path.
D37 possible_duplicates is returned to every proposer. Returned only if the caller's grant has read; a propose-only grant gets an empty list. Otherwise a propose-only grant could probe the content of a scope it may not read.
D38 supersedes may name a rule of another scope; the answer for a foreign scope is not specified. The scope of supersedes is part of the scope check: a scope outside the grant → 403 scope-not-allowed, not 422. Same answer whether the scope exists or not (step 2).
D39 Order of CSRF and scope check on the decision route is not specified. CSRF is checked before the scope check. The token travels as header csrf (JSON) or form field csrf (forms). An invalid rule id in the path → 400 validation-failed. A cross-site request learns nothing about scopes.
D40 predecessor-not-confirmed carries the predecessor. current holds the predecessor ref (rule/<id>), not the rule being decided. The caller needs to know which rule blocks the decision.
D41 A superseded rule stops being valid when the successor is confirmed. A superseded rule stays in force until its successor starts (valid_from of the successor); only then does a hit on it resolve to the successor. The successor may start in the future.
D42 Create-grant has no uniqueness rule. POST /ops/grants → 409 grant-exists when the same identity already has an active grant of that kind (unique constraint). One identity, one active grant; an access change is a new grant after disabling (D20).
D43 A scope listed in config but without a file is a configuration error. Reading it answers 503 scope-locked (ScopeNotCreated) until groundnote scope create has run. Same observable state as any scope that cannot be served; no existence oracle.

As built: HTTP, MCP and storage details#

# Contract Groundnote Why
D44 MCP requests with any origin are accepted if the credential is valid. A request with an Origin header other than the origin of public_url → 403 csrf-failed; clients without Origin pass. MCP transport guidance against DNS rebinding.
D45 Invalid tool input is answered with validation-failed. The MCP SDK validates tool input first and answers with a tool result isError: true and the text "Input validation error", not our code. Errors raised inside the tools carry the usual error code. The SDK check cannot be replaced without bypassing it.
D46 MCP scopes returns counts per scope. Adds locked; confirmed and proposed are null for a locked scope. A locked file does not answer (invariant 7), but the caller should see that the scope exists for it.
D47 GET /v1/status counts per scope. Same for status: a locked scope is {scope, counts: null, locked: true} and is reported with 200, not 503. One locked scope must not hide the others.
D48 Not specified. GET/DELETE /mcp → 405 method-not-allowed. The server is stateless; there is no stream to open or session to end.
D49 JWKS unreachable or invalid is not specified. Rethrown as 500 internal; an invalid token is 401. An Access outage is not a bad credential; clients must not drop a valid token.
D50 Errors are JSON ErrorBody. Errors on /approval/* are server-rendered HTML pages carrying the same status and code; a failed dev login is a 401 login page. Approval is used in a browser.
D51 Not specified. GET /approval/style.css is served without authentication. Static, contains no data; the login page needs it.
D52 Keywords are a list. In approval forms keywords are one comma-separated field, split on commas. Relations are shown read-only in the UI (JSON route only). Keeps the form free of client JavaScript (D34).
D53 Search stage code lives in search/, shared schemas in domain/schemas.ts. Search request/response schemas live in src/search/schemas.ts; domain/schemas.ts holds the shared primitives (Day, Instant, ids, Relation, Link) once. Avoids an import cycle between domain and search.
D54 bundle.thread entries carry the excerpt. Entry excerpts are the start of the text, at most 300 characters, ending with … when cut. Thread entries are what the feeder reported. A thread must not return full mail bodies to every reader.
D55 CLI is a thin client of the ops routes. groundnote serve loads main.ts through a dynamic import() in src/ops/cli.ts; ops code itself never imports main. groundnote, --help and -h print usage and exit 0. Keeps ops free of the service wiring; the bin is the composition root.
D56 Logging is not specified. src/log.ts: JSON lines on stdout, fields limited to ids, codes and counts. No tokens, bodies or source text in logs.
D57 Config access.team_domain is a URL. Must be https:// in cloudflare-access mode (start refused with team-domain-not-https); the file schema itself accepts http/https for dev. The issuer and the JWKS fetch must not run over plain HTTP.
D58 Not specified. The source language is not stored. Quotes, excerpts and earlier quotes on the approval pages are rendered with lang="" (unknown) instead of inheriting lang="en". Planned: an optional language on POST /v1/sources. WCAG 3.1.2: a German quote must not be read out as English; guessing the language would be wrong as often as right.

Knowledge graph (built, graph.md)#

Against datenmodell.md "Wissensgraph" (G1–G8) and api-vertrag.md section H as revised on 2026-11-05. Computed links instead of copies (formerly "no edge table") are in the contract and need no entry.

# Contract Groundnote Why
D59 entity.area; error area_mismatch. Column entity.scope (trigger: equals meta.scope); error scope-mismatch. "Bereich" is scope everywhere else; one word per concept.
D60 GraphError {code, message, remedy, candidates?} with snake_case codes incl. not_found, forbidden; cycle is 409. The existing ErrorBody with error in kebab-case plus remedy, next, candidates, paths (both paths on cycle), limit; not_found → not-found, forbidden → scope-not-allowed/channel-not-allowed. Added codes has-children, parent-archived, parent-unconfirmed (approval and parent checks). One error shape and one code style per API; clients branch on error.
D61 link_propose, link_list, GET /v1/links carry no scope; entity_tree needs an id; archive body {reason, cascade?}. scope is required where source or attachment refs carry none; entity_tree with only scope (and GET /v1/entities?scope=&page=) lists the top level; archive also takes version (409 when outdated). Step 3 of the scope check needs a scope; an agent must find the top level without knowing a name; a verdict applies to what was shown.
D62 entity_version has no change kind or channel; review {id, target, verdict, by, at, reason}. entity_version.change (create·move·update·confirm·revert·archive) and channel (mcp·api·approval); review stores decided_by, decided_by_identity, decided_at (API by, at) and new_version. "Agent version without review" must be exact, not guessed from text or grant (a person grant writes through MCP as an agent); identity as in D14.
D63 Rule 7 counts versions (rules only). backup_state.version_count counts rule_version + entity_version + link + review. A restore that loses structure must lock like one that loses rules.
D64 file_incoming_mail starts with regeln_suchen with eingang; assign_document starts with herkunft. search_rules with text (no inbox before Phase 4, D30); provenance only when the document is a rule's source or attachment (provenance takes a rule ref). Phase 1 surface.
D65 revert on an agent version writes the state before it; confirm reviews one version. Both act on the entity's current version and cover all its pending agent versions since the last approval version: confirm writes one review per pending version (plus active for an unconfirmed create); revert restores the state before the first pending version (a create → archived). Otherwise an older pending version could never be reviewed once a newer one exists (reviews only apply to the current version). Identical when one version is pending.
D66 POST /v1/sources (revised): 201 {created, id}; 409 other-feeder is its own code; both 409 reveal nothing (R3); no later additions of attachments. Aligned: 201 {created: true, id} / 200 {created: false, id} for an identical repeat of the same grant; no later attachment additions (a different attachment list, also a longer one, is a conflict); every conflict, including another feeder, is one generic 409 source-conflict with the same message; the reason (other-feeder, source-differs, attachments-differ) goes only to the process log. Stricter than the revision's own other-feeder code: a separate code would tell a caller that some other grant stored this id (R3). No feeder was released before the change.
D69 Ops "bound to 127.0.0.1" (D27). No TCP ops port: the ops listener is the Unix socket ops.sock in the data directory, mode 0600; GROUNDNOTE_OPS_PORT is removed and refuses the start if set. The CLI only sends the token to a socket owned by its own user. The data dir is created 0700 under umask 077 and the start is refused if it or a SQLite file in it is group- or world-accessible. Threat model R7 and F2: any local user could bind the loopback port first and collect the ops token; file permissions bind ops and the data to the owning user.
D71 Export export/<bereich>/ with verlauf.jsonl, nightly (Phase 2 here), for the scope. GET /v1/export/:scope is on the approval channel, only for persons with decide on the scope (services 403 channel-not-allowed); it answers a .tar.gz of <scope>/INDEX.md, <id>.md, history.jsonl, manifest.json (architecture 4.4); verlauf.jsonl is history.jsonl (glossary); the export contains rejected rules (INDEX section "Rejected", their files, all rows) although invariant 8 hides them on every other read path; history.jsonl comes from an explicit table allowlist (incl. the graph tables of 0002), not a denylist; the access goes to the process log (export), not to search_log, whose operation CHECK has no export yet. Stricter than the contract (security review of 517df1d): the export holds rejected rules and the e-mails of deciding persons, which only the approval channel shows; a read service token must not get them. Machine backups use the ops socket and the backup copies. An allowlist exports a new table only after a decision. Logging into search_log needs the operation in the CHECK (next scope migration).

Approval modes (slice A, approval-modes.md, built)#

Against zielbild.md decision 6, datenmodell.md "Freigabe-Modi", "Leitplanken", rule 2 and the tables entscheidung/klassifizierung, and api-vertrag.md (config freigabe, section B) as revised on 2026-11-05.

# Contract Groundnote Why
D72 freigabe is a required config block; project default klassifiziert. approval is optional; without it every scope runs manual. Inside the block mode is required; the shipped example config sets classified. An upgrade must not start adopting rules nobody decided to adopt; the project default lives in the example config.
D73 interne_absender: z.array(z.string()).min(1); no place for the trusted MTA. internal_senders may be empty (then every mail is external); entries are lowercase domains or addresses; new key authserv_ids (trusted Authentication-Results authserv-ids), required as soon as internal_senders is non-empty. An empty list only makes classification stricter; DMARC results are only meaningful from the operator's own MTA (RFC 8601 §5).
D74 "DMARC pass laut Authentication-Results"; how headers reach the service is not specified. Mail sources take an optional auth {from, authentication_results[]} on POST /v1/sources, stored in source_auth; internal only if stored by a service grant, from listed, and the first header with a trusted authserv-id has dmarc=pass with a matching header.from (approval-modes.md §4). The service never sees the raw message; a person grant (whose client a model may drive) must not vouch for authentication; lower headers can be forged by the sender.
D75 A Jamie speaker is internal when mapped to a participant with an internal address. Jamie sources are always external (sender-jamie). Groundnote stores speaker names, not participants or addresses; a name proves nothing. Decided 2026-11-06 (approval-modes.md §16.2): external until the feeder delivers participants with verified internal addresses.
D76 Guardrail 1: rules a person files as manuell are unaffected; "ein Modell kann manuell nicht wählen". A manual proposal is never adopted automatically (mandatory exception unchecked-quote, also in the schema). In Groundnote any grant with propose, including MCP, may file manual (D12); otherwise a model could skip the quote check.
D77 widerspruch: ersetzt set, or stage a finds a confirmed rule with the same relations and overlapping keywords; "eine mögliche Dublette fällt unter widerspruch". contra-supersedes, contra-overlap (≥ 1 shared relation and ≥ 1 shared keyword with a confirmed rule open today) and contra-duplicate (a possible-duplicate head with coverage ≥ 0.8 or an equal normalized statement). possible_duplicates is the top 3 of stage a without a threshold; taken as is, almost every proposal in a filled scope would be a contradiction and mode A would never adopt.
D78 geheimnis: key formats, PEM header, "Passwort", "PIN", "TAN" … → 422; the source excerpt is shown as a redaction preview on the exceptions page. Key formats, PEM, URL credentials and keyword with a value → 422 secret-detected; a keyword without a value is the sensitive class access. A secret_finding row (source ref, pattern ids, grant, time; no text) lists the case on the exceptions page; there is no redaction preview. "Passwords are never sent by mail" is a valid rule, not a secret; redaction is not built (architecture.md §15.2), so the page can only point at the source.
D79 A non-readable attachment always leads to review. Only when the sender is external (attachment-unreadable). Inline logos in internal signatures have no text and would stop every internal mail; the contract's concern ("siehe Anhang") is the external sender. Decided 2026-11-06 (approval-modes.md §16.3): stays.
D80 "sensible Klassen" are not enumerated. Ruleset r1 defines personal-data, legal, pricing, access with fixed patterns and keyword lists (approval-modes.md §5.3); only these may appear in classes. Mode B needs a deterministic list; a change bumps the ruleset version (hash test). Decided 2026-11-06 (approval-modes.md §16.4): the four classes stay.
D81 klassifizierer_modell: boolean; stage c may add a class; klassifizierung.modell holds its verdict. model_classifier accepts only false; classification.model is always NULL (CHECK). No model and no stage c in Phase 1 (D2); a switch that does nothing must fail at start.
D82 Optional daily kurzueberblick over an output; doctor warns at the daily limit. digest accepts only null; GET /approval/auto?scope=&since= is the list the digest would be made from; at the limit the exceptions page shows a banner and the process log gets approval-daily-limit. Outputs (D2, Phase 4) and doctor (architecture.md §15.3) are not built.
D83 entity_move and other structure changes get a status "nach Freigabe-Modus"; a subtree over teilbaum_max_knoten becomes a request (approval_required); the auto list includes structure. Structure is never adopted by a mode in this slice: agent writes keep graph.md K6 (in effect at once, pending until a person reviews). A move of more than subtree_max_nodes entities is listed as mandatory exception large-subtree; it is not held back. Structure adoption needs a policy writer in src/structure/** reachable from MCP; holding a move needs a person path that applies it, which does not exist (§15.5). Decided 2026-11-06 (approval-modes.md §16.5): structure adoption is the next slice.
D84 entscheidung.herkunft person·automatisch; wer = automatik@<regelwerk>. decision.origin grant·auto; decided_by = policy@<ruleset> for auto; new columns mode and classification_id; decided_by_identity is NULL for auto and for a retract by a service grant. A retract may come from a service token, which is no person; the decision row must name the classification it rests on (trigger).
D85 zuruecknehmen only "wenn die Bestätigung herkunft: automatisch trägt". Allowed while the newest confirm/review decision of the rule is the policy's: a person's review after adoption makes the rule person-owned (409 not-auto-adopted). version is required (ref with @n in MCP); valid_until = later of today and valid_from. A person who answered "still valid?" has taken responsibility; a verdict applies to what was read; the schema needs valid_until ≥ valid_from.
D86 Injection is checked in "Zitat, Aussage oder Ausschnitt"; the other classes name no text. Secret, payment and sensitive classes are checked in the proposal (statement, quote, source link, keywords, relations, speaker); the source text (excerpt, jamie window, readable attachments, up to 200 000 characters each, longer is uncertain) feeds only injection and, for external senders, external-sensitive. Company footers carry the company's own IBAN: scanning internal source text would stop every internal mail; an external mail with bank details stops even when the statement is harmless.
D87 Example answer of an adoption: "ref": "regel/…@1". The answer names the confirmed version, rule/<id>@2; version 1 stays the proposal. Adoption writes a new version like every confirmation; the ref names what is valid.
D88 Reasons zahlung, widerspruch, extern-sensibel, tagesgrenze, modus, detector hits as pruefung. Plus unchecked-quote and uncertain; pruefung is review; a class set to review is reported as mode without naming the class. Every refusal names its reason; the detector and the class lists stay invisible to a prober.
D89 An auto-adopted rule is marked in every answer. In MCP text an auto-adopted statement also stays inside an untrusted block marked auto-adopted; only a person-confirmed statement is human-confirmed. A model wrote it from foreign text and nobody read it; labelling it human-confirmed would be false provenance (invariant 11).
D90 zahlung: IBAN and bank details trigger the exception; nothing is said about numbers written in words or split by other characters. Ruleset r3 adds the doubt digit-run (uncertain, every mode): after the skeleton every non-alphanumeric character is dropped and digit words (null … neun, zwo, zero … nine) count as digits; 6 digit words in a row, 10 digits in a row, an IBAN country code with 12 digits within 120 characters, or 16 digits within any 120 characters (r4: words between the groups only fill the window; r5: every digit in the window after a capital country code written alone, O/l/I inside a run with digits read as 0/1, compound number words such as neunundachtzig as two digits) is held for a person, also when the mod-97 check fails or cannot be read. The IBAN join has no separator limit; r4 adds the payment stems begleich…, rechnung…, invoice…, zahlbar at word starts. A separator limit or a word form is a bypass (r2 was adopted autonomously with 9 tabs, 9 U+2800 or the IBAN in words). Im Zweifel sensibel: dates glued to times, phone and 10-digit order numbers go to review (approval-modes.md §5.3b).
D91 geheimnis: "Passwort", "PIN", "TAN" … → 422; a credential without a value is not named; zugang and the like are a sensitive class that mode B may set to auto. Ruleset r3 reads credential keywords structurally (default-ignorables removed, dashes inside a word and spaced single letters joined, stems anywhere in a word: Router Passwords, Kennwort-WLAN, Adminpwd, PIN-Code) and needs no operator: a strong keyword with any value-like token in its own segment or the next non-empty segment (the following sentence or line) is 422 secret-detected, a glued operator included (passwort:abc!def); r5 adds Geheimnummer, Türcode and other code compounds, Kennw. and one-edit typos of Passwort/Password/Kennwort; token compounds (Zugangstoken, Accesstoken) are strong; the bare weak keywords Schlüssel, key, token and code ist count only with a value after them in the same segment, or in the next one after a label such as Token: (r7) (a passphrase Sonne.Mond-Stern counts by its two inner dots or dashes). A keyword without a value, and every other access keyword, is the mandatory exception access (outward review): never adopted in any mode, and classes.access is refused in the config. Operator lists and gap limits were bypassed (PIN-Code: 4711, WLAN-Passwort → …, a soft hyphen) and stored credentials were auto-adopted and served to readers. Im Zweifel sensibel: the German contract only rejects secrets; we also hold every mention of access for a person, and accept false positives such as "Die PIN ist 4-stellig" (422).

CLI, doctor and backup execution (slice 15.3, built, cli.md)#

Against the German contract's CLI, backup and export sections and against Phase 2 of architecture section 12. Command renames (grant → grants) are in glossary.md.

# Contract Groundnote Why
D92 No route to read a stored source; sources are read through provenance. GET /v1/sources/:id?scope=&include= (architecture 6.2a): the same checks as POST /v1/sources (scope in the grant, right propose) and only sources whose feeder is the caller's grant; an unknown id, another grant's id and an id of another scope give the same 404 not-found; the answer has no feeder and carries content only with include=text / include=attachments. Logged as process log line source-read, not in search_log (its operation CHECK has no read_source yet). A feeder must be able to check what it stored without a second copy of its own. Bound to the feeder, the route cannot tell anyone that another grant stored an id (threat model R3, D66); persons and agents keep reading sources through provenance. Content only on request (data minimisation).
D93 The service runs the backup (sqlite3 .backup copy, Litestream supervision) and schedules the nightly export at export_time. The service never schedules: POST /ops/backup/run copies on request (VACUUM INTO from a read-only handle in a worker thread, ops.sqlite first, inspected, no count regression, atomic rename), and an OS timer calls groundnote backup nightly (cli.md §8). No export_time key. Litestream supervision stays with the operator. A timer of the host (systemd, launchd) survives restarts, catches up after downtime (Persistent=true) and is what operators already monitor; a scheduler inside the service would need its own persistence and would stop with the service. VACUUM INTO needs no sqlite3 binary and is the snapshot verify already uses. Copying ops.sqlite first keeps a restored pair from locking a scope (invariant 7).
D94 The nightly export is a scope export; architecture D71: services cannot export. POST /ops/backup/probe writes the nightly Markdown export of each passed scope into backup.export_directory (plaintext .tar.gz plus .sha256, mode 0600), built from the restored backup. Triggered by the holder of the ops token. The ops token holder owns the data directory and can read every SQLite file; the archive gives them nothing new. D71 keeps read service grants on the API from getting rejected rules and deciding persons' e-mails, which still holds. Exporting the restored file proves the backup and the archive at once. Encryption follows with redaction R5.
D95 The daily restore probe is backup verify plus a dead-man ping, run by the service. groundnote backup nightly (CLI) calls run, then probe, and only if both succeeded sends one GET to backup.deadman_url (https, loopback http, 10 s, no redirect, no credential). The probe restores through the real restore path into .probe-* and adds pair-consistent. The ping means "the whole nightly chain passed", which only the caller of both steps knows; the service makes no outbound call from an ops route. A stopped service, a failed copy or a failed probe sends no ping, so the switch alerts.
D96 Not specified (redaction.md §11 sketches doctor). groundnote doctor with the checks of cli.md §6, one finding {id, status: ok|warn|fail|skip, message, remedy} each; exit 0 / 3 / 1 (no report possible) / 2; an unreadable groundnote.json is a fail finding, not exit 1. The checks are pure functions; the stage b/c checks hash the model files on the host and read the month's stage c spending from GET /ops/status. doctor exists to diagnose a broken setup, so a broken config must be reported, not abort the report. Pure checks are testable without a host and are reused by the service for /v1/status.
D97 groundnote grant add / grant disable (and decisions only on the approval page). The CLI is a thin client of the public API with the caller's own credential and rights, plus the ops commands; grant is renamed grants (list, add, disable) without an alias (unreleased); there is no command that confirms, rejects, revokes, reviews, archives, reverts or redacts; approvals open prints the approval link only. One group noun per resource in the plural (proposals, sources, grants); an alias would keep two names alive. A decision needs a person in the browser with CSRF (5.3); AT6 keeps the decision path out of the CLI's import closure and route table.
D98 Not specified. Exit codes stay 0 ok, 1 error, 2 usage, 3 a check failed; no codes for "unauthorized" or "unreachable". With --json a failure also prints the error body on stdout. Human output goes to stderr, foreign text in it framed by renderResultForModel. The stable error code (stderr, and stdout under --json) already tells the causes apart; a timer only needs ok / failed / check failed. Agents call the CLI, so foreign text must reach them framed as on MCP (invariant 11).
D99 Not specified. GET /v1/status gains checks: [{id: "backup-age" | "restore-probe", status}]: the worst status over the grant's scopes, without message, remedy or path. Lets an API client or agent see a stale backup without ops access; ids and statuses reveal no path, no other scope and no content.
D100 A 401 carries only error: "unauthorized". Every 401 of the public listener (also /mcp) adds one fixed remedy (ask the operator for a grant, with the groundnote grants add line); the WWW-Authenticate header is unchanged, the ops listener's 401 has none. A person without a grant needs a next step; one text for every cause (no credential, wrong token, unknown identity, no active grant) tells nobody which of them applies.

Auf GitHub bearbeiten