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 sourcelinkwhen given, every keyword, every relationnameandref,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 aspd-email, since it is the sender §4 judges, not content); mailexcerpt; jamie window (segments withstart_sintimestamp_s ± 120, as the quote check); thetextof every readable attachment of the source; none formanual. Every text is scanned up toSCAN_MAX_CHARS = 200_000; a longer text adds the doubtscan-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 Passwort 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:
- Zod (
400 validation-failed), as today. - 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): onesecret_findingrow;search_logproposewith outcome 422 andinput_sha256 = NULL. - One transaction (
db.transaction,propose()nests as a savepoint):propose()ofsrc/domain/proposals.tsunchanged:source-unknown,quote-not-found,reference-unknown(nothing stored, no classification row: the quote requirement holds in every mode), elserule+ version 1proposed.possible_duplicatesas today (returned only withread);classify()runs its own stage-a read forcontra-duplicate(§5.4), so the query runs twice for a grant withread.classify()→resolve()(§2) → oneclassificationrow (version 1).- Outcome
auto→adopt()(§7.2).
search_logproposewith outcome 201 and refsrule/<id>@1(+rule/<id>@2when 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 ofsrc/structure/**and no file ofsrc/decision/**outsidesrc/decision/policy/**. - AT1b the closure of
src/decision/policy/**contains no file ofsrc/decision/**outside it, nor ofsrc/structure/**,src/approval/**,src/http/**,src/mcp/**. - AT2
src/decision/**outsidepolicy/is imported only bysrc/http/routes/decision.tsandsrc/approval/**. AT2csrc/decision/policy/**is imported only bysrc/http/routes/proposals.ts,src/http/routes/retract.ts,src/mcp/tools/propose-rule.ts,src/mcp/tools/rule-retract.tsandsrc/approval/**. AT2d the named importproposeofsrc/domain/proposals.tsoccurs only insrc/decision/policy/intake.ts. - AT3
INSERT INTO rule_versiononly insrc/domain/proposals.ts('proposed'),src/decision/decide.ts,src/decision/policy/adopt.ts('confirmed') andsrc/decision/policy/retract.ts('superseded');INSERT INTO decisiononly indecide.ts,adopt.ts('auto'),retract.ts('retract') and migrations;rejected_ftsnever insrc/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,approvalorclassesat any depth (the test walks every nestedproperties,items,anyOf…). - AT6
src/decision/policy/**contains noINSERT INTO entity_version,revieworlink. - AT7 every entry of
MANDATORY_EXCEPTIONSas aclasseskey 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. |