Documentation pages

Groundnote — approval modes A, B, C with guardrails (slice A)#

Status: built, binding (2026-11-06). This file extends architecture.md the way graph.md does: it replaces the brief of section 15.1 and changes invariants 1, 2 and 11. Source of the contract: German zielbild.md decision 6, datenmodell.md rule 2, state transitions, tables entscheidung/klassifizierung, "Freigabe-Modi" and "Leitplanken"; api-vertrag.md config freigabe, "Was nach dem Vorschlag geschieht", zuruecknehmen, the exceptions page, "Automatisch Übernommenes sagt es", regel_zuruecknehmen and the errors 409 nicht-automatisch, 422 geheimnis-erkannt. Renames are in glossary.md ("Approval modes"), departures in abweichungen.md D72–D91, threats in security/threat-model.md ("Approval modes"). The operator decided every question of §16 on 2026-11-06; each decision keeps what this spec builds.

1. Decisions#

# Question Decision Rejected alternative
M1 Who adopts? Code only: the policy writer src/decision/policy/** turns a proposal into confirmed when the deterministic classification says auto. No model decides; model_classifier accepts only false (D81). A model as classifier: it can be steered by the text it judges.
M2 Where is the mode set? Only in groundnote.json (approval), read at start. No MCP tool, API route, approval form or CLI command sets or changes a mode. A setting on the approval page: a second writer of policy with its own audit trail; not needed in Phase 1.
M3 What if approval is missing? Every scope runs manual (today's behaviour). The shipped example config sets "mode": "classified", the project default (D72). Missing block = classified: an upgrade would start auto-adopting without anyone deciding it.
M4 Mandatory exceptions A fixed list in code (§5.4), never a config value; the validator rejects a config that names one. Configurable exceptions: the fraud door the contract closes.
M5 manual proposals Never adopted automatically (mandatory exception unchecked-quote, D76). Adopting them like the contract's guardrail 1: in Groundnote an MCP client may choose manual (D12), so a model could skip the quote check.
M6 Structure (graph) Not auto-adopted in any mode in this slice: agent entity and link writes keep graph.md K6 (effect at once, pending until a person reviews). A move of more than subtree_max_nodes entities is the mandatory exception large-subtree on the exceptions page (§11, D83). Auto-confirming small structure changes: needs a second writer in src/structure/** reachable from MCP; it can follow as its own slice.
M7 Second writer src/decision/policy/** (adopt(), retract(), intake()); src/decision/decide.ts stays the person path. MCP may reach the policy files, never decide.ts (AT1). Moving decide.ts into src/decision/person/: churn without a gain.
M8 Retract by whom rule_retract (MCP), POST /v1/rules/:id/retract (api), groundnote rule retract (CLI, api client), right propose; only while the newest confirm/review decision of the rule is the policy's (D85). A person on the approval page uses revoke. A retract on the approval channel: revoke already does it there.

2. Modes and how a proposal is resolved#

Mode (approval.mode, per scope approval.scopes.<s>.mode) German Proposal without mandatory exception Approval page
autonomous (A) autonom adopted, unless a sensitive class is set to review for the scope exceptions only
classified (B, project default) klassifiziert adopted if it touches no sensitive class, or only classes set to auto sensitive classes and exceptions
manual (C) standard waits for a person (reason mode) everything

Effective mode of a scope: approval.scopes[scope]?.mode ?? approval.mode, or manual without the block. Effective verdict of a sensitive class c: classes[c] ?? (mode === "autonomous" ? "auto" : "review"). The resolution, in this order (first match wins), is resolve() in src/decision/policy/resolve.ts:

# Condition Outcome reasons (outward) basis (stored)
1 a secret (§5.2) 422 secret-detected, nothing of the proposal stored — —
2 any mandatory exception (§5.4) review mapped exceptions (§5.5), plus mode if 3 or 4 also hold exception:<first>
3 effective mode manual review mode mode-manual
4 a touched sensitive class resolves to review review mode class-review:<class>
5 the scope has reached max_auto_per_day today (§12) review daily-limit daily-limit
6 otherwise auto [] mode-autonomous, classified-no-sensitive or class-auto:<c>[,<c>…]

Classification runs in every mode, also manual, so the log and the exceptions page are complete. A proposal that went to review is never adopted later by the policy (not on the next day, not after a config change); only a person decides it.

3. Configuration (src/config/approval.ts, key approval of groundnote.json)#

export const MODES = ["autonomous", "classified", "manual"] as const;
export const SENSITIVE_CLASSES = ["personal-data", "legal", "pricing", "access"] as const;   // ruleset r7; access is also a mandatory exception
export const MANDATORY_EXCEPTIONS = ["payment", "secret", "contradiction", "external-sensitive",
  "injection", "unchecked-quote", "uncertain", "access", "large-subtree"] as const;        // never configurable (so `classes.access` is refused, D91)

const Domain  = z.string().regex(/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$/); // lowercase ASCII, IDN as punycode
const Address = z.string().max(320).regex(/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]{1,64}@[^@\s]+$/).refine(a => Domain.safeParse(a.split("@")[1]).success);
const ScopePolicy = z.object({
  mode: z.enum(MODES),
  classes: z.record(z.string(), z.enum(["auto", "review"])).optional(),   // keys: SENSITIVE_CLASSES only
}).strict();

export const ApprovalConfig = z.object({
  mode: z.enum(MODES),                                                    // required inside the block
  scopes: z.record(z.string(), ScopePolicy).default({}),                 // keys: configured scope names (validator)
  max_auto_per_day: z.number().int().min(0).max(10_000).default(20),     // per scope and zone day; 0 = never adopt
  amount_threshold_eur: z.number().min(0).max(1_000_000_000).default(1000),
  subtree_max_nodes: z.number().int().min(1).max(100_000).default(25),
  internal_senders: z.array(z.union([Domain, Address])).max(200).default([]),   // unique
  authserv_ids: z.array(Domain).max(10).default([]),                     // trusted Authentication-Results authserv-ids (D74)
  model_classifier: z.literal(false).default(false),                     // D81
  digest: z.null().default(null),                                        // D82
}).strict();
// in ConfigFile (architecture.md §3): approval: ApprovalConfig.optional()

Validator (superRefine on ConfigFile, so it sees scopes), each a start refusal with a named message and the config path:

Check Message
a key of approval.scopes is not a configured scopes[].name approval-scope-unknown
a classes key is one of MANDATORY_EXCEPTIONS (any value, also "review") mandatory-exception-not-configurable
a classes key is neither sensitive nor mandatory approval-class-unknown
classes present while that scope's mode is manual approval-classes-in-manual
internal_senders has duplicates approval-sender-duplicate
internal_senders non-empty and authserv_ids empty (no mail could ever be internal) internal-senders-without-authserv-id
model_classifier not false, digest not null Zod literal failure (as semantic, D2)

effectivePolicy(config, scope): {mode, classes} lives next to the schema; nothing else reads approval.scopes. The validator is checkApproval in src/config/approval.ts, applied by ConfigFile.superRefine; approval.ts imports nothing of the project (§14), so scope keys are plain strings checked against scopes[].name (every configured name is a ScopeName).

4. Mail authentication and sender (src/domain/sources.ts, src/decision/policy/sender.ts)#

Today a source stores sender as free text and no header. The mail variant of SourceRequest (architecture.md §6.2) gains an optional block; everything else is unchanged:

const MailAuth = z.object({
  from: Address,                                            // RFC 5322 From addr-spec, lowercased by the feeder
  authentication_results: z.array(z.string().min(1).max(4000)).min(1).max(20),  // raw header values, top of the message first
}).strict();
// mail source object: …, auth: MailAuth.optional()

Stored in source_auth (§7) in the transaction that stores the source; part of the identity check of a repeat (absent vs present, or different → 409 source-conflict). It is never shown outside the approval channel and never in a bundle.

Internal (sender = "internal", basis sender-internal) only when all hold, else external with the first failing basis id (stored in classification.sender_basis):

# Condition Basis id when it fails
1 the source is mail sender-jamie (jamie, D75) · sender-manual (manual: sender = "none")
2 the source was stored by a service:* grant (source.feeder) sender-feeder-not-service
3 a source_auth row exists sender-no-auth
4 from equals an address of internal_senders, or its domain equals a domain entry (exact, no subdomains) sender-not-listed
5 the first authentication_results value whose authserv-id (RFC 8601: the token before the first ;, comments removed, lowercased, version ignored) is in authserv_ids sender-authserv-untrusted
6 that header has a dmarc=pass resinfo whose header.from equals the domain of from sender-dmarc-<result> (fail, none, temperror, permerror, missing) · sender-header-from-mismatch
— a header that does not parse (above or at the first trusted one; an unknown dmarc result counts as unparsable) sender-unparsable

Only the first trusted header counts: the operator's receiving MTA prepends its own and must strip foreign ones with its authserv-id (operator note in the threat model, AM3). dmarc=pass is read as the result, independent of the domain's published policy. Header values, from and the config entries are compared as ASCII only (lowercasing A–Z): any other character makes the verdict sender-unparsable, because Unicode case folding maps some characters onto ASCII (KELVIN SIGN U+212A → k) and a forged authserv-id or domain could otherwise match a trusted one (parser differential, threat model R6).

5. Classification (src/decision/policy/ruleset.ts, classify.ts, ruleset r7)#

5.1 Ruleset and scan targets#

RULESET = "r7" and RULESET_SHA256 (SHA-256 of the canonical JSON of every pattern source and keyword list, in code). A test recomputes the hash and fails when it differs, so any change to a pattern or list must bump the version (r2 closed the separator, look-alike and compound bypasses found in review of r1; r3 replaced single patterns by structural rules after bypasses of r2: the IBAN join has no separator limit, the doubt digit-run (§5.3b) reads numbers behind any separator and in words, a credential keyword is read structurally and needs no operator, and the class access is a mandatory exception; r4 lets words between digit groups only fill a window and adds the payment stems begleich…, rechnung…, invoice…, zahlbar; r5 reads look-alike digits, compound number words, more credential words, typos and the following segment; r6 keeps the country code at the start of a run out of the look-alike reading; r7 splits glued operators, reads weak keywords only in their own segment and starts an IBAN join only at a registry country code). Every pattern the scans use is a table entry in the hash, including the helper patterns (marks, letter and space runs, token, word, digit and letter classes); only control flow is code. classify() is a pure function of the request, the stored source, source_auth, the scope reads of §5.4 and the config; it returns ids only. Code: classify(db, request, {ruleId, now, search, approval}) reads (readSourceFacts, readContradictions) and calls the pure classifyFacts; findSecrets(proposalTexts(request).map(t => t.text)) in secrets.ts is the §5.2 scan intake runs before propose().

  • P (proposal text): statement, quote, the source link when given, every keyword, every relation name and ref, speaker. The link is served with the rule, so credentials in it are a secret like in the statement.
  • S (source text): mail subject (title) and display sender (sender; its address is not read as pd-email, since it is the sender §4 judges, not content); mail excerpt; jamie window (segments with start_s in timestamp_s ± 120, as the quote check); the text of every readable attachment of the source; none for manual. Every text is scanned up to SCAN_MAX_CHARS = 200_000; a longer text adds the doubt scan-truncated.

Shapes read the skeleton. The secret scan, every shape pattern (IBAN, BIC, account, amount, e-mail, phone) and the keyword normalizer read the skeleton of the text: every Default_Ignorable_Code_Point removed (soft hyphen U+00AD, zero-width space and joiners, bidi controls …, so Pass­wort reads as Passwort; the injection detector still sees them in the raw text); NFKC; CONFUSABLES folded (the letter part of the UTS #39 skeleton for Cyrillic and Greek: а е о р с у х ѕ і ј һ ԁ … and Α Β Ε Η Ι Κ Μ Ν Ο Ρ Τ Υ Χ ο ι … → their Latin look-alike; digits are not folded); ä ö ü Ä Ö Ü ß ẞ → ae oe ue Ae Oe Ue ss SS; then NFD with every mark removed, so É, Í, ñ read as E, I, n. So fullwidth DE89 …, AKIA…, a Cyrillic ІBAN, Bаnkverbindung, ÍBAN or an IBAN head DÉ89 read as ASCII. A text that is pattern-scanned (P, and S of an external sender) adds a doubt for what the skeleton cannot settle: non-ascii-digits (on NFKC: a decimal digit NFKC leaves non-ASCII, Arabic-Indic, Devanagari …, pattern (?![0-9])\p{Nd}: the shapes only read ASCII digits); mixed-script (on NFKC: a word, a run of [\p{L}\p{M}\p{N}], with letters of two of Latin, Greek, Cyrillic, Armenian, Cherokee); combining-mark (on NFKC: a mark \p{M} left on a letter or digit, I̵BAN); foreign-letter (on the skeleton: any letter outside ASCII, i.e. one without a Latin base, such as Coptic ⲟ, Lisu ꓮ, ı, Ð, ø, or any letter of a non-Latin script the table does not fold). All four are fail-closed: German text (umlauts, ß, accents) raises none, but genuine Greek, Cyrillic, CJK or Devanagari text and names with ø, æ, ł in P raise uncertain, and folded Cyrillic words may read as a Latin keyword (more review, never less). Linear time. The plain patterns start at a fixed literal and have no requirement after an unbounded run. sec-jwt, sec-url-credentials and pd-email would backtrack quadratically as regexes, so they are anchored scans with the same meaning: sec-jwt over the runs of [A-Za-z0-9_-] (three runs joined by single dots), the IBAN over the runs of [A-Za-z0-9] (§5.3, each start reads at most 34 characters), sec-url-credentials at each :// (the scheme run before it holds a letter, the userinfo pattern matches after it), pd-email at each @ (a local-part character before it, the domain pattern after it). No start is forbidden and no length is capped, so nothing that precedes an occurrence hides it; a test scans 200 KB adversarial inputs in under 200 ms.

Keyword match: normalize both sides (skeleton, which already holds ä→ae ö→oe ü→ue ß→ss, lowercase, every character that is not a letter or digit → space, collapse spaces). A keyword of at most 4 normalized characters matches only as a whole token; a longer one, and every multi-word keyword, matches as a substring of the normalized text (German compounds: bankverbindungsaenderung).

5.2 Secret (reject) — P only#

Pattern id Matches
sec-pem -----BEGIN [A-Z0-9 ]+-----
sec-aws \b(AKIA|ASIA)[0-9A-Z]{16}\b
sec-github \bgh[pousr]_[A-Za-z0-9]{36,255}\b, \bgithub_pat_[A-Za-z0-9_]{22,}
sec-groundnote \bgn_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-]) (a token may end with -, where \b would miss it)
sec-slack · sec-stripe · sec-google · sec-ai-key \bxox[abprs]-[A-Za-z0-9-]{10,} · \b(sk|rk)_(live|test)_[A-Za-z0-9]{16,} · \bAIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-]) · \bsk-(ant-)?[A-Za-z0-9_-]{20,}
sec-jwt \beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,} (scanned over runs, §5.1)
sec-url-credentials [a-z][a-z0-9+.-]*://[^/\s:@]+:[^/\s@]+@, case-insensitive (anchored at ://, §5.1)
sec-assignment read per segment of the skeleton (split at line breaks and at ., !, ?, ; before a space). A segment names a credential when its keyword form (lowercase, runs of three or more single letters joined: P a s s w o r t → passwort) holds a short keyword pin, tan, otp, pass, pw (also with s) as a word or a dash-separated part (PIN-Code, TAN-Nummer, TAN12345678), or, with dashes between two letters removed (Pass-wort, WLAN-Passwort), a stem anywhere in a word: passw (passwort, password, passwords, passwörter, passwd), kennwort, kennwoert, pwd (Adminpwd), zugangsdat, geheimnummer, the abbreviation kennw (Kennw.), code after tuer, tor, zugangs, alarm, tresor, safe, sicherheits, wlan, wifi, zahlen (Türcode), credential, secret, passcode, passphrase, geheimzahl, api key/access key/private key (space, _ or nothing between), and schluessel after zugangs, sicherheits, netzwerk, wlan, wifi, api, lizenz. r5 also takes one-edit typos of passwort, password (deletion, insertion, swap, replacement) and kennwort (no replacement, so Kennwert stays neutral) inside a word (Paswort, Pasword, Kenwort). Such a segment with any value-like token is a secret, no operator needed (Passwort ∶ …, Passwort – …, WLAN-Passwort → …, Zugangsdaten: admin / …); a segment with a strong keyword and no value also reads the next non-empty segment, on the same line or the next (r5: Hier das neue WLAN-Passwort. Es lautet …, Das Passwort habe ich geändert. Neu: …, Das Passwort lautet and the value on the next line). Token compounds are strong: access, auth, bearer, zugangs, api, admin, refresh, session before token(s) (space, _, dash or nothing between) and any letters glued before a word-final token(s) (Zugangstoken, Accesstoken, Admin-Token; not Tokenizer). Weak keywords (r7), bare key(s), schluessel(n), token(s) as a word or dash-separated part, name a credential only with a value-like token after them in the same segment, or in the next one when theirs ends in a label (:, =, >, →, ⇒, ∶, ist, is, lautet, lauten, sind, are: Token: and the value on the next line) (Der Schlüssel fürs WLAN: …, Wifi key is …), code(s) before :, =, ist, lautet, is, sind (Der Code ist 4711); so Key points. Revenue 2026 grew., Der Code ist sauber. Version 2.0.1 …, Verteilerschlüssel. Umsatz 2026 … and Tokenizer v2 nutzt 4096 Tokens are no secret (token is still access-kw). A token (a run of non-space characters, cut of what is neither letter nor digit at both ends; r7: also each part around a glued operator :, =, →, ⇒, ∶, ->, so passwort:abc!def, password=foo#bar, pwd=Sonne#Mond count) is value-like with ≥ 4 characters and a digit (4711, 482913, Gx7!k2pQ, 4-stellig), or letters with one of !@#$%^&*_+=?/|~<>{}[]()§ inside, or with at least two dots or dashes between letters or digits (Sonne.Mond-Stern, correct-horse-battery-staple; a single one is E-Mail and does not count), when it names no credential itself (ab#cd!ef, not Login/Passwort). Known false positives, kept fail-safe: "Die PIN ist 4-stellig" (4-stellig), "Passwort ändern bis 12.03.2026" (a date), Secretary 2026, a date in the sentence after a strong keyword ("Das Passwort wird nie verschickt. Die Lieferung ist 2026-03 fällig."), a number after a weak keyword in its segment ("Den Schlüssel bis 12.03.2026 abgeben", "Der Code ist in Version 2.0.1 fertig", "Key figures: 2026")

A hit → 422 secret-detected, body {error: "secret-detected", message: "The proposal contains credentials; nothing was stored."}, no pattern id, no part of the value. Nothing of the proposal is stored; one secret_finding row (§7) records scope-level facts without text so a person sees on the exceptions page that a source holds credentials. A credential keyword without a value is not a secret: it is the class access (D78), which r3 also makes the mandatory exception access (review in every mode, never auto, D91).

5.3 Classes — P only (S feeds only external-sensitive)#

Class Kind Pattern ids (r7)
payment mandatory pay-iban (runs of ASCII letters and digits joined whatever and however many other characters stand between two runs: space, _, ,, ., ·, -, ‑, tabs, a line break with indentation, |, *, :, ;, +, /, U+2800, zero-width …; a join stops at 34 characters, so the scan stays linear; the joined text starts with a country code of the IBAN registry (r7, case-insensitive) and 2 check digits and has 15–34 characters; ISO 13616 mod 97 = 1 for the join cut after any run, whatever the group lengths, so a following 4-letter word does not hide it) · pay-iban-shape (the same join with invalid check digits, either one unbroken run or groups of 4 with a last group of 1–4: doubt) · pay-bic (8 or 11 character BIC within 40 characters after bic or swift) · pay-account (konto, kontonummer, kto, account number, account no, blz, sort code, routing number followed within 20 characters by 6–12 digits, up to 3 characters that are neither letters, digits nor a line break between two digits) · the BIC needs no separator rule: bic and swift are pay-kw on their own · pay-amount (amount ≥ amount_threshold_eur, §5.3a) · pay-kw (keywords below)
personal-data sensitive pd-email (an e-mail address, anchored at @, §5.1) · pd-phone (\+?\d[\d ()/.-]{6,}\d with ≥ 8 digits) · pd-kw (geburtsdatum, geboren am, date of birth, krankmeldung, krankheit, krank, diagnose, arbeitsunfaehig, gehalt, lohn, abmahnung, personalakte, sick, illness, diagnosis, medical, salary)
legal sensitive legal-kw (vertrag, vertragsstrafe, kuendigung, haftung, gewaehrleistung, garantie, dsgvo, datenschutz, agb, nda, geheimhaltung, anwalt, klage, gericht, contract, liability, warranty, guarantee, gdpr, terms and conditions, lawyer, lawsuit, court, termination)
pricing sensitive price-amount (an amount below the threshold) · price-kw (preis, rabatt, nachlass, aufpreis, zuschlag, stundensatz, marge, price, discount, surcharge, margin)
access sensitive and mandatory (§5.4) access-kw (a segment that names a credential, §5.2, without a value, zugang, anmeldedaten, login, berechtigung, zugriffsrecht, admin, 2fa, mfa, permission, access rights)

pay-kw (paraphrases and stems, both languages; a stem over 4 characters matches inside compounds, §5.1): bankverbindung, bankdaten, bankwechsel, bank gewechselt, bank geaendert, neue bank, konto (also kontoverbindung, kontowechsel, empfaengerkonto, skonto), zahlung (also zahlungen, zahlungsziel, anzahlung, abschlagszahlung), ueberweis, ueberwies (ueberweisung, ueberweisen, ueberwiesen), lastschrift, einzugsermaechtigung, sepa, vorkasse, iban, bic, swift, bank details, bank account, account details, wire transfer, bank transfer, remit (remittance), payment (payment terms, payments), pay, paid (whole tokens), at the start of a word only (r4, so Berechnung does not count) begleich (begleichen), rechnung (Rechnungen), invoice, zahlbar; überweisen an is ueberweis; direct debit, new account, change of bank, beneficiary, and the pattern net \d{1,3} (payment terms).

5.3a Amounts. A number \d{1,3}(?:[.,'  ]\d{3})+(?:[.,]\d{1,2})?|\d+(?:[.,]\d{1,2})?, optionally with a multiplier (k, tsd, tausend, thousand, mio, million, millionen), and a currency (€, eur, euro, usd, $, chf, gbp, £) at most 3 non-alphanumeric characters before or after. A last separator followed by 1–2 digits is the decimal separator, followed by 3 digits a grouping. Other currencies are compared by their number (no conversion). ≥ threshold → payment, below → pricing. An amount written in words cannot be read: a normalized token that is a number word (eins … zwoelf, mio, mrd, tsd, one … twelve, twenty … ninety, or a token holding hundert, tausend, million, milliard, zwanzig, dreissig, zig, zehn, hundred, thousand, billion, teen: viertausendachthundert) at most AMOUNT_WORDS_GAP = 1 token before or after a currency word (euro, euros, eur, usd, dollar(s), chf, franken, gbp, pfund, pound(s); the signs € $ £ count as eur usd gbp, since the normalizer drops symbols) adds the doubt amount-words (uncertain), in P and in S of an external sender: fünftausend €, EUR zweitausend, $ five thousand, zweitausend fünfhundert Euro. One pass over the tokens with a fixed window.

5.3b Digit runs (r3–r7). A number a person reads as one can stand behind any separator or be written in words, so the IBAN check alone is not enough. The skeleton is lowercased, runs of three or more single letters separated by spaces are joined (n u l l → null), and a stream is built of its letter runs and ASCII digit runs, with every other character dropped (spaces of any kind, tabs, line breaks, U+2800, punctuation, symbols, default-ignorables). A letter run made only of the digit words null eins zwei zwo drei vier fuenf sechs sieben acht neun zero one … nine becomes their digits (achtneun → 89; r5 also reads the compound German number words zehn … neunzehn, zwanzig … neunzig and einundzwanzig … neunundneunzig as two digits, neunundachtzig → 89, the longest word first). r5: inside a run of letters and digits that holds a digit, O/o reads as 0 and l/I as 1 (37O4OO → 370400, Ol3OOO → 013000). r6/r7: a run that starts with a registry country code and a digit keeps those two letters (NL91… stays nl91, not n191); any other prefix is read as look-alikes (lO56 → 1056), and a registry country code there opens the same 120-character window as a country code written alone (NL91 und ABNA und 0417 …). The doubt digit-run (uncertain, in P and in S of an external sender) is raised for 6 digit words in a row, a run of 10 digits in the stream, a country code of the IBAN registry with two digits right after it and 12 digits within the next 120 stream characters, or 16 digits within any 120 stream characters (r4: letters and whole words between the groups only fill the window, DE89 und 3704 und 0044 …; prefix sums over the digit positions, linear); r5: after a country code written alone in capitals (DE (Prüfziffer 89) …) every digit in its 120-character window counts, whatever stands between. pay-iban stays the exact signal; a valid IBAN raises both. Known false positives, kept fail-safe: a capital country code such as ES or IT before 12 digits of dates or numbers within 120 characters; a word with o, l or i glued to digits counts them as digits (Halle3); a date glued to a time (12.03.2026 14:30 → 12 digits), phone numbers, order numbers of 10 digits, and a word ending in a country code followed by close numbers (… bis 15.03.2026 und 18.03.2026), two dates and a number close together (16 digits). Neutral text such as Lieferzeit 3 Wochen, 2026-03-12 or KW45 raises nothing, nor does one date with a short order number (Bestellung 48213 vom 12.03.2026). Building and checking the stream are one pass each (prefix sums for the window).

5.4 Mandatory exceptions (every mode; exceptions column)#

Exception Trigger (pattern / basis ids)
payment any pay-* pattern in P
contradiction contra-supersedes (supersedes set) · contra-overlap (a rule of the scope, current version confirmed and open today, that shares ≥ 1 relation — same kind and normalized name, or equal non-null ref — and ≥ 1 normalized keyword) · contra-duplicate (a head of the possible-duplicates query, §6, with coverage ≥ DUPLICATE_COVERAGE = 0.8, or with a normalized statement equal to the proposal's) (D77)
external-sensitive sender external and (a sensitive class in P, or a pay-*, pd-*, legal-*, price-* or access-* pattern in S) — the "from now on new bank details" case even when the statement itself is harmless
injection any detectInjectionSignals hit (src/security/injection-signals.ts, incl. hidden-characters) in P or S; stored as <signal-id>@<field> (statement, quote, link, keywords, relations, speaker, title, sender, excerpt, window, attachment:<n>), never the match
unchecked-quote source kind manual (D76)
uncertain scan-truncated; non-ascii-digits, mixed-script, combining-mark, foreign-letter (§5.1); amount-words (§5.3a); digit-run (§5.3b); attachment-unreadable (sender external and the source has an attachment with text_origin = 'none', D79); classifier-error (any exception thrown inside classify(): the proposal is stored with this outcome, sender = 'external', sender_basis = 'classifier-error', and the process log gets classify-error with the error name)
access the class access in P (§5.3): a credential keyword without a value, or an access keyword. Outward reason review, so the class stays on the approval channel; approval.scopes.<s>.classes.access is refused as mandatory-exception-not-configurable (D91)
large-subtree structure only (§11)
secret §5.2; never reaches the table (rejected)

5.5 Outward reasons (ProposalCreated.reasons, pages, read routes)#

In this order, without duplicates: payment, external-sensitive, contradiction, review (= injection or access; nothing else about the detector or the class leaves the approval channel), unchecked-quote, uncertain, mode, daily-limit. Pattern ids, signal ids, classes and sender_basis are shown only on the approval channel.

6. Proposal path (src/decision/policy/intake.ts)#

intake(db, request, context): IntakeResult replaces the direct call of propose() in src/http/routes/proposals.ts and src/mcp/tools/propose-rule.ts (AT2d). Order:

  1. Zod (400 validation-failed), as today.
  2. Secret scan over P → 422 secret-detected (§5.2). In its own transaction (the caller's access-log transaction, which stores nothing of the proposal): one secret_finding row; search_log propose with outcome 422 and input_sha256 = NULL.
  3. One transaction (db.transaction, propose() nests as a savepoint):
    1. propose() of src/domain/proposals.ts unchanged: source-unknown, quote-not-found, reference-unknown (nothing stored, no classification row: the quote requirement holds in every mode), else rule + version 1 proposed.
    2. possible_duplicates as today (returned only with read); classify() runs its own stage-a read for contra-duplicate (§5.4), so the query runs twice for a grant with read.
    3. classify() → resolve() (§2) → one classification row (version 1).
    4. Outcome auto → adopt() (§7.2).
  4. search_log propose with outcome 201 and refs rule/<id>@1 (+ rule/<id>@2 when adopted).
const Reason = z.enum(["payment", "external-sensitive", "contradiction", "review",
  "unchecked-quote", "uncertain", "mode", "daily-limit"]);
const ProposalCreated = z.object({
  ref: RuleRef,                                  // auto: rule/<id>@2 (the confirmed version, D87); review: @1
  status: z.enum(["proposed", "confirmed"]),     // widened from the literal "proposed"
  adoption: z.enum(["auto", "review"]),
  valid: z.boolean(),                            // status === "confirmed"
  marking: z.string().nullable(),                // auto: "auto-adopted on <YYYY-MM-DD in zone>, source <source id>"; review: null
  reasons: z.array(Reason),                      // review: ≥ 1; auto: []
  quote_check: z.object({ checked: z.boolean() }),
  possible_duplicates: z.array(RuleRef).max(3),
  approval_url: z.url(),                         // also for auto: the rule page shows the decision
});

Breaking: no for mode manual (status, ref as today, fields added); status and ref change only where an operator turns a scope to A or B.

7. Schema: migration src/db/migrations/scope/0004-approval.ts#

7.1 Tables#

CREATE TABLE classification (                 -- append-only; one row per classified proposal version
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  rule_id TEXT NOT NULL, version INTEGER NOT NULL,
  ruleset TEXT NOT NULL CHECK (ruleset GLOB 'r[1-9]*'),
  mode TEXT NOT NULL CHECK (mode IN ('autonomous','classified','manual')),
  classes TEXT NOT NULL CHECK (json_valid(classes) AND json_type(classes) = 'array'),          -- payment + sensitive classes touched
  exceptions TEXT NOT NULL CHECK (json_valid(exceptions) AND json_type(exceptions) = 'array'),
  patterns TEXT NOT NULL CHECK (json_valid(patterns) AND json_type(patterns) = 'array'),       -- "<pattern-id>@P" | "@S", then bare contra-* and doubt ids
  detector TEXT NOT NULL CHECK (json_valid(detector) AND json_type(detector) = 'array'),       -- "<signal-id>@<field>", never text
  sender TEXT NOT NULL CHECK (sender IN ('internal','external','none')),
  sender_basis TEXT NOT NULL,
  model TEXT CHECK (model IS NULL),                                                            -- D81
  outcome TEXT NOT NULL CHECK (outcome IN ('auto','review')),
  reasons TEXT NOT NULL CHECK (json_valid(reasons) AND json_type(reasons) = 'array'),
  basis TEXT NOT NULL,
  classified_at TEXT NOT NULL CHECK (classified_at GLOB '????-??-??T??:??:??*Z'),
  FOREIGN KEY (rule_id, version) REFERENCES rule_version(rule_id, version),
  UNIQUE (rule_id, version),
  CHECK (outcome = 'review' OR (json_array_length(exceptions) = 0 AND json_array_length(reasons) = 0
                                AND mode <> 'manual' AND sender <> 'none')),
  CHECK (outcome = 'auto' OR json_array_length(reasons) >= 1));

CREATE TABLE source_auth (                    -- append-only; mail only
  source_id TEXT PRIMARY KEY REFERENCES source(id),
  from_address TEXT NOT NULL CHECK (length(from_address) BETWEEN 3 AND 320
                                    AND from_address = lower(from_address) AND instr(from_address, '@') > 1),
  authentication_results TEXT NOT NULL CHECK (json_valid(authentication_results)
    AND json_type(authentication_results) = 'array' AND json_array_length(authentication_results) BETWEEN 1 AND 20)
) WITHOUT ROWID;
CREATE TRIGGER source_auth_mail_only BEFORE INSERT ON source_auth
WHEN (SELECT kind FROM source WHERE id = NEW.source_id) IS NOT 'mail'
BEGIN SELECT RAISE(ABORT, 'auth-not-mail'); END;

CREATE TABLE secret_finding (                 -- append-only; no text, no hash of the value
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  source_id TEXT REFERENCES source(id),       -- NULL for manual or an unknown source
  ruleset TEXT NOT NULL, patterns TEXT NOT NULL CHECK (json_valid(patterns) AND json_type(patterns) = 'array'),
  grant_id TEXT NOT NULL, channel TEXT NOT NULL CHECK (channel IN ('mcp','api')),
  found_at TEXT NOT NULL CHECK (found_at GLOB '????-??-??T??:??:??*Z'));

decision is rebuilt (SQLite cannot change a CHECK), with the procedure 0002 used for search_log: drop its triggers, create decision_new, copy every row with origin = 'grant', mode and classification_id NULL, drop, rename, recreate the append-only pair and the triggers below; ids and sqlite_sequence are kept; PRAGMA foreign_key_check is empty afterwards.

CREATE TABLE decision (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  rule_id TEXT NOT NULL, version INTEGER NOT NULL,
  kind TEXT NOT NULL CHECK (kind IN ('confirm','reject','revoke','review','retract')),
  origin TEXT NOT NULL CHECK (origin IN ('grant','auto')),
  decided_by TEXT NOT NULL,                   -- person:<n> | service:<n> (retract only) | policy@<ruleset>
  decided_by_identity TEXT,                   -- email iff decided_by is a person grant
  decided_at TEXT NOT NULL CHECK (decided_at GLOB '????-??-??T??:??:??*Z'),
  reason TEXT, new_version INTEGER,
  mode TEXT CHECK (mode IS NULL OR mode IN ('autonomous','classified')),
  classification_id INTEGER REFERENCES classification(id),
  FOREIGN KEY (rule_id, version) REFERENCES rule_version(rule_id, version),
  FOREIGN KEY (rule_id, new_version) REFERENCES rule_version(rule_id, version),
  CHECK (origin = 'grant' OR kind = 'confirm'),                                  -- contract: automatisch only with bestaetigen
  CHECK ((origin = 'auto') = (decided_by GLOB 'policy@r[1-9]*')),
  CHECK ((origin = 'auto') = (mode IS NOT NULL AND classification_id IS NOT NULL)),
  CHECK (origin = 'auto' OR decided_by GLOB 'person:*' OR (kind = 'retract' AND decided_by GLOB 'service:*')),
  CHECK ((decided_by GLOB 'person:*') = (decided_by_identity IS NOT NULL)),
  CHECK (kind NOT IN ('reject','revoke','retract') OR length(trim(coalesce(reason, ''))) > 0),
  CHECK (kind = 'review' OR new_version IS NOT NULL),
  CHECK (new_version IS NULL OR new_version > version));
CREATE UNIQUE INDEX decision_one_auto ON decision(classification_id) WHERE classification_id IS NOT NULL;

CREATE TRIGGER decision_auto_guard BEFORE INSERT ON decision WHEN NEW.origin = 'auto' BEGIN
  SELECT RAISE(ABORT, 'auto-without-classification') WHERE NOT EXISTS (SELECT 1 FROM classification c
    WHERE c.id = NEW.classification_id AND c.rule_id = NEW.rule_id AND c.version = NEW.version
      AND c.outcome = 'auto' AND c.mode = NEW.mode AND 'policy@' || c.ruleset = NEW.decided_by);
  SELECT RAISE(ABORT, 'auto-unchecked-quote') WHERE (SELECT source_id FROM rule_version
    WHERE rule_id = NEW.rule_id AND version = NEW.version) GLOB 'manual:*';
  SELECT RAISE(ABORT, 'auto-changed-content') WHERE NOT EXISTS (SELECT 1 FROM rule_version p
    JOIN rule_version n ON n.rule_id = p.rule_id AND n.version = NEW.new_version
    WHERE p.rule_id = NEW.rule_id AND p.version = NEW.version AND p.status = 'proposed' AND n.status = 'confirmed'
      AND p.supersedes IS NULL AND n.supersedes IS NULL AND n.kind IS p.kind AND n.statement IS p.statement
      AND n.keywords IS p.keywords AND n.relations IS p.relations AND n.valid_from IS p.valid_from
      AND n.said_at IS p.said_at AND n.speaker IS p.speaker);
END;
CREATE TRIGGER decision_retract_guard BEFORE INSERT ON decision WHEN NEW.kind = 'retract' BEGIN
  SELECT RAISE(ABORT, 'not-auto-adopted') WHERE coalesce((SELECT origin FROM decision
    WHERE rule_id = NEW.rule_id AND kind IN ('confirm','review') ORDER BY id DESC LIMIT 1), 'grant') <> 'auto';
  SELECT RAISE(ABORT, 'invalid-transition') WHERE NOT EXISTS (SELECT 1 FROM rule_version n
    WHERE n.rule_id = NEW.rule_id AND n.version = NEW.new_version AND n.status = 'superseded' AND n.superseded_by IS NULL);
END;
CREATE TRIGGER classification_guard BEFORE INSERT ON classification WHEN NEW.outcome = 'auto' BEGIN
  SELECT RAISE(ABORT, 'auto-unchecked-quote') WHERE (SELECT source_id FROM rule_version
    WHERE rule_id = NEW.rule_id AND version = NEW.version) GLOB 'manual:*';
  SELECT RAISE(ABORT, 'auto-supersedes') WHERE (SELECT supersedes FROM rule_version
    WHERE rule_id = NEW.rule_id AND version = NEW.version) IS NOT NULL;
END;
-- append-only pair (no UPDATE, no DELETE) on classification, source_auth, secret_finding and the rebuilt decision

search_log and feedback are rebuilt the same way (0002 procedure) to add the operations retract, list_exceptions and list_auto. EXPORTED_TABLES gains classification, source_auth and secret_finding (architecture.md §4.4; the allowlist test enforces the choice). meta.schema_version = 4.

7.2 Single writer per field#

Table / column Only writer Literal the AT3 pattern checks
classification src/decision/policy/intake.ts INSERT INTO classification
secret_finding src/decision/policy/intake.ts INSERT INTO secret_finding
source_auth src/domain/sources.ts INSERT INTO source_auth
rule_version v2 confirmed by the policy, decision origin = 'auto' src/decision/policy/adopt.ts 'confirmed', 'auto'
rule_version superseded by retract, decision kind = 'retract' src/decision/policy/retract.ts 'superseded', 'retract'
every other rule_version/decision row as today (proposals.ts, decide.ts) as today

adopt(db, ruleId, classification, context): version 2 = copy of version 1 with status 'confirmed', reason = "auto-adopted by policy@r1 (<mode>)", changed_by = 'policy@r1', changed_at = now; decision kind 'confirm', origin 'auto', decided_by 'policy@r1', identity NULL, version 1, new_version 2, mode, classification_id; reindexRule. It never changes content and never supersedes (trigger).

last_reviewed_at (architecture.md §6.4) counts the policy's confirm like a person's, so the recheck clock starts at adoption.

8. Retract (src/decision/policy/retract.ts)#

Surface Input Output
POST /v1/rules/:id/retract (api, right propose) {version: int ≥ 1, reason: string 1–1000} strict 200 {ref: "rule/<id>@<n>", status: "superseded", valid_until: Day}
MCP tool rule_retract (right propose) {ref: RuleRef with @<n> required, reason: string 1–1000} the same object
CLI groundnote rule retract <rule/id@n> --reason <text> [--json] thin client of the route with the api credential (cli.md §3): GROUNDNOTE_URL + GROUNDNOTE_API_TOKEN (service bearer) or GROUNDNOTE_API_ACCESS_JWT (a person), or the same keys in cli.json human output on stderr, framed by model-output.ts, or the JSON on stdout with --json (cli.md §1, D98); exit 0 / 1 (code on stderr) / 2

Checks in order: blank reason → 400 reason-missing; Zod → 400 validation-failed; the §5.2 secret scan over reason → 422 secret-detected, body {error: "secret-detected", message: "The reason contains credentials; nothing was stored."} (MCP: {error: "secret-detected"}), no version, no decision, no secret_finding row (the reason belongs to no source); unknown, other scope or rejected → 404 not-found; current status superseded → 409 already-decided; version ≠ current → 409 version-outdated; status proposed, or the newest confirm/review decision has origin = 'grant' → 409 not-auto-adopted (remedy text: "A person confirmed or reviewed this rule; only a person can revoke it on the approval page."). Every 409 carries current. Effect, one transaction: version superseded, valid_until = max(today in zone, valid_from), superseded_by NULL, reason = given, changed_by = grant; decision kind 'retract', origin 'grant', decided_by = grant, identity = email of a person grant else NULL; FTS; search_log retract. Retracts do not refill the daily limit. A service grant may retract like a person grant (§16 decision 6); nothing it retracts disappears from the history.

rule_retract description (verbatim): "Retract a rule that the policy adopted automatically (its marking says auto-adopted). Give the version you read and the person's reason. A rule that a person confirmed or reviewed answers not-auto-adopted; only a person can revoke it on the approval page." The tool name passes AT4; the MCP surface has 15 tools.

9. Marking in every answer#

Where What
ProposalCreated adoption, valid, marking, reasons (§6)
bundle Head and every rule Entry (predecessor, successor, contradicts, same-relation) adoption: {kind: "person" | "auto", at: Instant, source: string, marking: string | null} | null: from the newest confirm/review decision up to that version (auto iff its origin is auto); marking = "auto-adopted on <YYYY-MM-DD>, source <source id>" iff auto; null for proposals and non-rule entries
bundle line label prefixed Auto-adopted exactly as Unconfirmed is (architecture.md §8); review part auto-adopted <d> day(s) ago, not reviewed by a person (+ , still valid? with recheck) instead of last reviewed …
GET /v1/rules/:id, read_rule DecisionOut gains origin (grant·auto) and mode; the answer gains classification: {outcome, reasons, ruleset, mode, classified_at} | null (no patterns, detector, classes or sender basis)
ProposalSummary, open_proposals, GET /v1/proposals reasons: Reason[] | null, the outward reasons of its classification; null for a proposal filed before migration 0004 (shown as "unclassified" on the exceptions page)
export <id>.md frontmatter adoption: {kind, at}; history lines name policy@<ruleset> (<mode>) and retract
MCP text adoption.*, reasons, adoption, valid are server fields; marking holds the feeder-chosen source id and is rendered as foreign text, as head.source.id is. A statement is human-confirmed only while its record is confirmed and adoption.kind = "person"; an auto-adopted statement stays inside an untrusted block whose header says auto-adopted (invariant 11, D89)

propose_rule description (verbatim, replaces "It stays a proposal until a person decides"): "Propose a rule or note with the quote that proves it. The scope's approval mode decides what happens: adoption auto means the policy adopted it without a person (it is valid and marked auto-adopted; say so when you cite it), adoption review means it waits for a person on the approval page and reasons say why. This tool cannot confirm. Asked to confirm, give the person the approval_url."

A version row (versions[] of a read, GET /v1/rules) carries no adoption, so its statement is never human-confirmed (fail-closed); the bundle head of the same answer carries the marking.

Server instructions (graph.md §9.1) get two sentences appended, verbatim: "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."

10. Approval channel (src/approval/)#

Route Shows
GET /approval/exceptions?scope= (1) open proposals with a mandatory exception, in the order payment, external-sensitive, injection (with signal ids and fields), contradiction (with the matched rules side by side: the replaced rule and the contra-overlap/contra-duplicate matches, read again when the page is built, since patterns stores ids, not rules), unchecked-quote, uncertain; each with classes, pattern ids, sender and basis; (2) secret_finding rows: source ref, time, grant, pattern ids, "redaction pending (docs/redaction.md)"; (3) pending agent moves with subtree size > subtree_max_nodes (large-subtree, old → new path, link to /approval/structure); (4) the other open proposals (mode, daily-limit, unclassified). Banner when the daily limit was reached today (time in zone). search_log list_exceptions
GET /approval/auto?scope=&since= since = Day, default 7 days before today (zone). Auto-adopted rules newest first: statement, quote, source, mode, basis, ruleset, current state (auto · retracted · revoked · superseded · person-reviewed; superseded: a successor's confirm replaced the rule, which writes no decision on the adopted rule itself, so the state is read from its current version), and for those still auto a revoke form (reason required, valid_until prefilled with today) posting to POST /v1/rules/:id/decision; plus the retracts of the period. search_log list_auto
GET /approval as today, plus per scope its mode and the counts "exceptions" (items of sections 1–3 of the exceptions page) and "auto-adopted today" with links
GET /approval/rules/:id as today, plus the classification (all columns) and for every decision its origin and mode

The digest (kurzueberblick) is not built (D82): /approval/auto is the list it would be made from.

11. Structure: large-subtree#

Agent structure writes are unchanged (graph.md K6, §12). The policy writes no entity_version, review or link row (AT6). A pending agent move version whose subtree_size (the moved entity and all descendants, read from entity_closure when the page is built; no size is stored) exceeds subtree_max_nodes is the mandatory exception large-subtree: listed first among structure items on the exceptions page and marked on /approval/structure. When structure adoption is built later, these moves stay excluded; until then the guarantee is that no structure is ever confirmed by a mode.

12. Daily limit#

Per scope: the number of decision rows with origin = 'auto' and decided_at inside today's calendar day in config.zone (bounds from src/domain/time.ts, as UTC instants), counted inside the intake transaction. count ≥ max_auto_per_day → outcome review, reason daily-limit; the first such proposal of a scope and day writes the process log line approval-daily-limit (scope, count). Retracted adoptions still count.

13. Logging#

classification + decision are the complete record (contract rule 4). Neither stores text of a proposal or source; detector and patterns hold ids. The process log gets codes only: secret-detected (grant, scope, pattern count), classify-error (error name), approval-daily-limit. search_log rows of propose with outcome 422 secret-detected have input_sha256 = NULL: a hash of a short PIN could be brute-forced.

14. Module cut and architecture tests#

Path Owns May import
src/config/approval.ts ApprovalConfig, MODES, SENSITIVE_CLASSES, MANDATORY_EXCEPTIONS, effectivePolicy —
src/decision/policy/ruleset.ts RULESET, RULESET_SHA256, pattern and keyword tables, normalizer —
src/decision/policy/iban.ts · amount.ts · digits.ts · secrets.ts mod 97, amount parser, digit-run stream, secret scan ruleset
src/decision/policy/sender.ts §4 internal/external config, ruleset
src/decision/policy/classify.ts · resolve.ts §5 (pure) · §2 db (read), domain, search (duplicates), security (injection signals), config
src/decision/policy/intake.ts §6 transaction, classification and secret_finding writer the above, domain/proposals.ts (propose), src/log.ts (process log, §13)
src/decision/policy/adopt.ts · retract.ts §7.2 · §8 writers db, domain, search (reindexRule via db/fts)
src/http/routes/retract.ts POST /v1/rules/:id/retract auth, decision/policy
src/mcp/tools/rule-retract.ts rule_retract decision/policy
src/ops/cli-commands.ts (dispatched from cli-run.ts, as export) rule retract config, domain (parseRuleRef), security (model-output.ts)

Architecture tests (test/arch/architecture.test.ts), replacing AT1–AT3 where named:

  • AT1 the import closure of src/mcp/** contains no file of src/structure/** and no file of src/decision/** outside src/decision/policy/**.
  • AT1b the closure of src/decision/policy/** contains no file of src/decision/** outside it, nor of src/structure/**, src/approval/**, src/http/**, src/mcp/**.
  • AT2 src/decision/** outside policy/ is imported only by src/http/routes/decision.ts and src/approval/**. AT2c src/decision/policy/** is imported only by src/http/routes/proposals.ts, src/http/routes/retract.ts, src/mcp/tools/propose-rule.ts, src/mcp/tools/rule-retract.ts and src/approval/**. AT2d the named import propose of src/domain/proposals.ts occurs only in src/decision/policy/intake.ts.
  • AT3 INSERT INTO rule_version only in src/domain/proposals.ts ('proposed'), src/decision/decide.ts, src/decision/policy/adopt.ts ('confirmed') and src/decision/policy/retract.ts ('superseded'); INSERT INTO decision only in decide.ts, adopt.ts ('auto'), retract.ts ('retract') and migrations; rejected_fts never in src/decision/policy/**; the three new tables per §7.2.
  • AT4 unchanged regex; the tool list is exactly 15 names; no tool input schema has a key mode, approval or classes at any depth (the test walks every nested properties, items, anyOf …).
  • AT6 src/decision/policy/** contains no INSERT INTO entity_version, review or link.
  • AT7 every entry of MANDATORY_EXCEPTIONS as a classes key is refused by the validator, with "auto" and with "review".

15. Slices and required tests#

Each slice ends green on pnpm lint && pnpm typecheck && pnpm test, one commit per step.

# Slice Acceptance tests (file → cases)
A1 config + 0004 test/config/approval.test.ts: absent block = manual for every scope; each validator row of §3; model_classifier: true and digest: {} refused; defaults 20 / 1000 / 25. test/db/migrations-0004.test.ts: a 0003 file with decisions migrates with equal ids, rows and sqlite_sequence, foreign_key_check empty; UPDATE/DELETE abort on the four tables; auto decision without classification, with a review classification, with another version, twice, on a manual source, with changed content or with supersedes aborts; retract after a person confirm or review aborts not-auto-adopted; the decision CHECKs (auto with an identity, service grant on confirm) abort
A2 sources auth + sender test/domain/sources-auth.test.ts: stored with the source, repeat identical 200, differing auth 409. test/decision/policy/sender.test.ts: internal with listed domain + trusted dmarc=pass; forged internal sender: listed From without auth, with dmarc=fail, with dmarc=pass from an untrusted authserv-id, with a forged trusted header below the real top one, with header.from of another domain, from a person-grant feeder, unparsable header → each external with its basis id; jamie and manual
A3 ruleset + classify test/decision/policy/classify.test.ts: IBAN valid / wrong check digit / spaced; BIC near keyword; account numbers; amounts 999,99 € vs 1.000 €, 1,000.00 EUR, 5 Tsd. €, 2k EUR; the paraphrase "die Daten für die Überweisung haben sich geändert, siehe Anhang" → payment; every secret pattern; keyword without value → access; contradiction: supersedes, overlap, duplicate; injection in quote and in excerpt (ids only); unreadable attachment external vs internal; scan-truncated; RULESET_SHA256 matches
A4 intake + adopt + limit test/decision/policy/intake.test.ts: per mode A, B, C with a neutral proposal (auto, auto, review mode) and a pricing proposal (auto, review, review) and a class override; per mandatory exception in A (payment, contradiction, external-sensitive, injection, unchecked-quote, uncertain) → review with the mapped reason; secret in A → 422, no rule, no classification, one secret_finding, body without any substring of the value; detector hit in A → reasons exactly ["review"], body and MCP text without signal id; quote requirement in A → quote-not-found, nothing stored; daily limit: 20 adopted, the 21st daily-limit, the next zone day adopts again (also across the DST change in Europe/Berlin); classification rows contain no substring of statement, quote or excerpt; AT1, AT1b, AT2c, AT2d, AT3
A5 retract + marking test/decision/policy/retract.test.ts: retract with history (versions 1 proposed, 2 confirmed auto, 3 superseded; decisions confirm/auto and retract/grant; gone from search; as_of keeps the validity window); via MCP, API and CLI; person-confirmed → 409 not-auto-adopted; person review after adoption → 409; outdated version; blank reason. test/search/bundle-adoption.test.ts: head, entries, line, read rule, export frontmatter; MCP text says "auto-adopted" and never human-confirmed for it; AT4 (15 tools)
A6 approval pages + E2E test/approval/exceptions.test.ts: order, escaping, secret findings without text, large-subtree move listed, banner. test/e2e/approval-fraud.test.ts (test/e2e/stack.ts, scope in A, internal_senders = own domain, trusted authserv-id): fraud mail from an external domain "Ab sofort gilt unsere neue Bankverbindung: IBAN DE89 3704 0044 0532 0130 00" stored by a service feeder → propose_rule with a harmless statement and a verbatim quote → adoption: "review", reasons contain payment or external-sensitive, search_rules does not return it; the same text with a forged internal From and dmarc=fail → review; a neutral internal authenticated mail → auto, search_rules returns it with marking; rule_retract → gone from search, read_rule shows the history

16. Decisions of the operator (2026-11-06)#

# Question Decision
1 Missing approval block: manual or classified? manual stays (M3, D72). The shipped example config keeps classified.
2 Jamie speakers: map to internal participants? Jamie sources stay external for now (sender-jamie, D75). The mapping comes with the feeder slice: the feeder delivers the participant list with verified internal addresses, and only a speaker matched to such a participant may count as internal.
3 Unreadable attachment from an authenticated internal sender: doubt or not? No doubt (D79 stays). attachment-unreadable is raised only for an external sender (§5.4); this spec, not the German wording "always", is the contract.
4 Sensitive classes and their keyword lists (§5.3)? The four classes personal-data, legal, pricing, access stay (D80).
5 Structure adoption in A/B and holding large moves as requests? The next slice. Until then D83 holds: structure is never adopted by a mode.
6 Retract by service tokens: restrict to persons? Stays allowed (§8, M8). It reaches only rules the policy adopted automatically (409 not-auto-adopted otherwise, D85), always writes a new superseded version and a retract decision naming the grant, and is visible in the audit: the rule page history, the retracts on /approval/auto and the export history.

Edit on GitHub