Seiten der Dokumentation

Diese Seite gibt es bisher nur auf Englisch.

Groundnote CLI (groundnote)#

Status: built (slice 15.3 of architecture.md, branch feat/cli): every command of section 2, doctor with the stage b/c checks, the stage-b commands model fetch|verify and vector-index rebuild|status (docs/search/stages-b-c.md §10) and the timer examples of section 8. This file stays the contract; change it here first, then the code.

The CLI is a client. It holds no rights of its own: every command goes either to the public HTTP API with the caller's credential (the same channels, rights and scope checks as MCP and HTTP, architecture section 5) or to the ops socket with the ops token (section 11). There is no command that confirms, rejects, revokes, reviews, archives, reverts or redacts, and there never will be (invariants 2 and 10); approvals open only prints the link to the approval page.

1. Conventions#

Topic Rule
stdout Only (a) the --json answer, exactly one line; (b) a new service token, once (grants add --kind service without --json); (c) the bare value of version, config path and approvals open. Nothing else, ever.
stderr Human output, warnings, and every error as <code>: <message>.
Foreign text Human output of an answer that holds foreign text (quotes, excerpts, statements, titles, senders, entity names and aliases, link reasons) is rendered by renderResultForModel (src/security/model-output.ts): foreign values stay inside untrusted blocks with a random boundary, as on MCP (invariant 11). Agents that call the CLI should read this output, not --json.
--json Every command accepts it. Success: the server's JSON answer verbatim (re-serialized on one line), or the CLI's own documented shape for local commands. Failure: the error body on stdout (ErrorBody, or {error, message} for a CLI-side error) and <code>: <message> on stderr. --json output is not framed: it is for programs.
Request bodies --body <file> or --body - (stdin) reads a JSON object. Flags fill fields; a flag that sets a field already present in the body is a usage error (flag-conflicts-with-body, exit 2), never a silent override. The final body is checked client-side with the server's own Zod schema (section 7); a failure is exit 2 with the issues on stderr.
Tokens Never in argv, never on stdout except a new service token once, never in logs or error messages. Credentials come only from the environment or cli.json (section 3).
Redirects Every HTTP call uses redirect: "error", so a redirect cannot carry a credential to another origin.
Help groundnote help, --help or -h lists every command with one line and a few examples (stderr, exit 0; no argument at all is the same list with exit 2). groundnote help <command> and <command> --help show usage, description and examples. --version and -V are version.

Exit codes#

Code Meaning Examples
0 ok answer printed; sources add stored (201) or identical repeat (200)
1 error: the call failed HTTP 4xx/5xx (not-found, unauthorized with its remedy, scope-not-allowed, source-conflict, rate-limited), api-unreachable, unexpected-response, ops-unreachable, ops-socket-untrusted, ops-socket-path-too-long, credentials-*, the ops environment (secret-missing, secret-too-short for GROUNDNOTE_OPS_TOKEN, invalid-env for the removed GROUNDNOTE_OPS_PORT), backup-running, target-exists, deadman-failed
2 usage: the invocation is wrong usage (unknown command or flag, invalid flag value, missing choice such as --speaker/--no-speaker), validation-failed (client-side schema check), flag-conflicts-with-body, body-invalid (--body unreadable, not JSON or not an object), body-too-large, url-invalid (config init --url), renamed (groundnote grant …)
3 a check failed backup verify, backup probe, the probe part of backup nightly, restore checks, doctor with any fail

No further codes (decision in abweichungen.md D98): the stable error code on stderr, and in --json on stdout, already tells unauthorized from api-unreachable; a timer only needs "ok / failed / check failed", and doctor names the cause.

2. Command overview#

Command Target Credential Right
search POST /v1/search api read
get GET /v1/rules/:id api read
bundle GET /v1/rules/:id (prints bundle) api read
provenance GET /v1/provenance/:id api read
proposals list GET /v1/proposals?scope= api read
propose POST /v1/proposals api propose
rule retract POST /v1/rules/:id/retract api propose, only auto-adopted versions
entity resolve GET /v1/entities/resolve api read
entity list GET /v1/entities?scope=&page= api read
entity tree GET /v1/entities/:id/tree api read
entity path GET /v1/entities/:id/path api read
entity create POST /v1/entities api propose
entity move POST /v1/entities/:id/move api propose
link list GET /v1/links api read
link propose POST /v1/links api propose
sources add POST /v1/sources api propose
sources get GET /v1/sources/:id (new) api propose, own sources only
scopes GET /v1/status (prints scopes) api read
status GET /v1/status api read
export GET /v1/export/:scope approval (person Access JWT) decide
approvals open none (prints a link) url only —
scope create POST /ops/scopes ops token ops
grants list GET /ops/grants (new) ops token ops
grants add POST /ops/grants ops token ops
grants disable POST /ops/grants/:id/disable ops token ops
backup run POST /ops/backup/run (new) ops token ops
backup probe POST /ops/backup/probe (new) ops token ops
backup nightly run, then probe, then dead-man ping ops token ops
backup verify POST /ops/backup/verify ops token ops
restore local files, no service file access —
doctor local files, GET /ops/status (new), GET /health (no credential), GET /v1/status all available —
config show · config path · config init local file — —
serve · version local — —

"api" means the service bearer GROUNDNOTE_API_TOKEN (gn_…) or a person's Access JWT for the api application (GROUNDNOTE_API_ACCESS_JWT); exactly one per invocation (section 3). Rights are those of the grant behind the credential; a service grant never has decide.

3. Configuration and credentials#

Precedence: if GROUNDNOTE_URL and at least one credential variable are set, the environment is the only source; otherwise cli.json. The two are never merged.

Env cli.json key Used by
GROUNDNOTE_URL url every api and approval command; https, http only for loopback
GROUNDNOTE_API_TOKEN token api commands as a service
GROUNDNOTE_API_ACCESS_JWT api_access_jwt api commands as a person (Access application with aud_api)
GROUNDNOTE_ACCESS_JWT access_jwt export only (Access application with aud_approval)
GROUNDNOTE_DATA_DIR, GROUNDNOTE_OPS_TOKEN, GROUNDNOTE_CONFIG — ops commands, restore, doctor

token and api_access_jwt set in the same source → credentials-ambiguous (exit 1): the service refuses a request with both (section 5.1), and the CLI does not pick one silently. The auds are distinct (section 3), so one Access JWT can never serve both api and approval; get each with cloudflared access token -app <url> for its application.

File. $XDG_CONFIG_HOME/groundnote/cli.json (absolute XDG_CONFIG_HOME, else ~/.config/groundnote/cli.json). Schema, strict:

const CliFile = z.object({
  url: z.url(),
  token: z.string().regex(/^gn_[A-Za-z0-9_-]{43}$/).optional(),
  api_access_jwt: Jwt.optional(),          // Jwt = three base64url parts
  access_jwt: Jwt.optional(),
}).strict().refine(c => c.token !== undefined || c.api_access_jwt !== undefined || c.access_jwt !== undefined);

The file must be a regular file of the calling user with mode 0600, else every command that needs it refuses with credentials-file-permissions (and doctor reports fail). Owner: the user; Groundnote writes it only through config init.

config path#

groundnote config path [--json] — prints the path the CLI reads (stdout, bare). --json: {"path": "…", "exists": true}.

config show#

groundnote config show [--json] — tells where credentials come from without showing them. --json: {"source": "environment" | "file" | "none", "path": "…", "url": "https://…" | null, "token": "set" | "not set", "api_access_jwt": "set" | "not set", "access_jwt": "set" | "not set", "file_mode_ok": true | false | null}. Never a token, a prefix of one, or its length.

config init#

groundnote config init --url <url> (--token-stdin | --api-access-jwt-stdin | --access-jwt-stdin) [--json]

Creates cli.json with exactly one credential. The secret is read only from stdin: piped → up to EOF, one line, trailing newline removed; a terminal → a prompt on stderr with echo off. There is no flag that takes a secret as a value (the process list is readable by other users). Checks the url (https, http only for loopback) and the secret's shape, creates the directory with mode 0700 if missing, writes the file with wx and mode 0600; an existing file is never overwritten (target-exists, exit 1: delete it first). --json: {"path": "…", "credential": "token" | "api_access_jwt" | "access_jwt"}, never the secret. Access JWTs expire within hours, so for export prefer the variable: GROUNDNOTE_ACCESS_JWT=$(cloudflared access token -app https://groundnote.example.org) groundnote export acme.

printf '%s\n' "$NEW_TOKEN" | groundnote config init --url https://groundnote.example.org --token-stdin

4. Read and write commands (api channel)#

Errors and codes are those of the HTTP API (architecture 6.6, graph.md for graph codes). Every id is checked client-side with the server's id schema (RuleId, EntityId, Endpoint) before a request is sent.

groundnote search [<text>] --scope <s>... [--cross-scope] [--term <t>]... [--relation <kind>:<name>]
                  [--as-of <YYYY-MM-DD>] [--include-proposals] [--limit <1-50>] [--entity <id>]
                  [--body <file|->] [--json]

Searches rules in one or more scopes and prints the bundles (head with date, source and quote, groups, line). <text> is text; --term adds to terms. Body: SearchRequest (src/search/schemas.ts). Empty result: the CLI prints abstained.reason and which stages ran.

groundnote search "Haken in RAL 9005, tiefschwarz" --scope acme
groundnote search --scope acme --term lieferzeit --relation customer:"Müller GmbH" --json

get, bundle, provenance#

groundnote get <rule-id> [--version <n>] [--json]          → {bundle, versions, decisions}
groundnote bundle <rule-id> [--version <n>] [--json]       → Bundle (the `bundle` field only)
groundnote provenance <rule-id> [--version <n>] [--json]   → Provenance

get shows a rule with all versions and decisions (DecisionOut names the grant, never the e-mail); bundle shows only what a search would show for it; provenance shows source, quote, excerpt or meeting window, thread and attachments. A rejected rule is not-found (invariant 8).

groundnote get acme-0012
groundnote provenance acme-0012 --version 2 --json

proposals list, propose#

groundnote proposals list --scope <s> [--json]
groundnote propose [--body <file|->] [--scope <s>] [--kind rule|note] [--statement <text>]
                   [--source-kind jamie|mail|manual] [--source-id <id>] [--quote <text>]
                   [--timestamp-s <n>] (--link <url> | --no-link) [--said-at <instant>] (--speaker <name> | --no-speaker)
                   [--relation <kind>:<name>]... [--keyword <k>]... [--valid-from <day>]
                   [--supersedes <rule-id>] [--json]

propose stores a proposed rule; it counts only after a person confirms it on the approval page, whose link the answer carries (approval_url). Body: ProposalRequest. There are no defaults: said_at, valid_from, relations ([] when no --relation), keywords ([]) and supersedes (null unless given) are filled exactly as stated. speaker is never filled: give --speaker <name> or --no-speaker (null) unless the body has it; with --source-* flags the link is stated the same way (--link <url> or --no-link; a jamie source needs a link). Otherwise exit 2 (usage). --relation sets ref: null; relations with a ref go through --body.

groundnote propose --scope acme --kind rule --statement "Schwarze Haken: immer erst die Lieferzeit abfragen" \
  --source-kind jamie --source-id jamie:m-2026-11-04 --timestamp-s 754 --link https://jamie.example/m/1 \
  --quote "bei den schwarzen Haken müssen wir immer erst die Lieferzeit abfragen" \
  --said-at 2026-11-04T09:12:34Z --speaker "J. Doe" --keyword haken --keyword lieferzeit --valid-from 2026-11-04
groundnote propose --scope acme --kind rule --statement "Lieferzeit vor Zusage prüfen" --source-kind manual \
  --quote "Lieferzeit vor Zusage prüfen" --no-link --said-at 2026-11-04T09:12:34Z --no-speaker --valid-from 2026-11-04
groundnote propose --body proposal.json --json

rule retract#

groundnote rule retract <rule/id@n> --reason <text> [--json]

Thin client of POST /v1/rules/:id/retract (docs/approval-modes.md §8). It ends a version the policy adopted automatically with a new superseded version; for anything else the service answers not-auto-adopted (exit 1), and a person revokes the rule on the approval page. Body: {version, reason} from the ref and --reason.

entity …#

groundnote entity resolve --scope <s> <name> [--type <t>] [--json]
groundnote entity list --scope <s> [--page <n>] [--json]
groundnote entity tree <entity-id> [--depth <1-3>] [--page <n>] [--json]
groundnote entity path <entity-id> [--json]
groundnote entity create [--body <file|->] [--scope <s>] [--type <t>] [--name <n>] [--alias <a>]...
                         (--parent <entity-id> | --top) [--evidence <text>] [--force --reason <text>] [--json]
groundnote entity move <entity-id> (--parent <entity-id> | --top) --reason <text> [--json]

Same functions and answers as the MCP tools (graph.md §9, §10): every answer carries path and next, every error a remedy. create and move take effect at once as unconfirmed versions; a person confirms on the approval page. --parent or --top is required, so a top-level entity is never created by omission. Body: CreateInput / MoveInput (src/graph/schemas.ts). Resolve before create (docs/agents.md §1).

groundnote entity resolve --scope acme "Sales North"
groundnote entity create --scope acme --type department --name "Sales North" --parent acme-e0002
groundnote entity move acme-e0007 --parent acme-e0020 --reason "reorganisation 2026-11"
groundnote link list --scope <s> --ref <endpoint> [--type <t>] [--grouped] [--page <n>] [--json]
groundnote link propose [--body <file|->] [--scope <s>] [--from <endpoint>] [--to <endpoint>]
                        [--type concerns|belongs_to|mentions|contradicts|same_as]
                        [--confidence <0-1>] [--reason <text>] [--evidence <text>] [--json]

propose stores a proposed link (201, or 200 when it exists). Body: ProposeLinkInput.

groundnote link propose --scope acme --from "source/mail:<2026-11-04.abc@example.org>" \
  --to entity/acme-e0011 --type belongs_to --confidence 0.9 --reason "subject and signature name the machine"

sources add, sources get#

groundnote sources add --body <file|-> [--scope <s>] [--json]
groundnote sources get <source-id> --scope <s> [--include text,attachments] [--json]

add stores a mail or meeting transcript (SourceRequest, body limit 10 MiB, checked before sending on the request as sent: JSON.stringify of the body after --scope is filled, so a pretty-printed file may be larger; over the limit is body-too-large, exit 2). --scope fills scope when the body has none. 201 and an identical repeat (200) both exit 0; the answer tells created. A conflict is source-conflict (exit 1) with one generic message (D66). get reads back a source this grant stored (architecture 6.2a): metadata, fulltext_sha256 and the attachment list; --include text adds the mail text or the segments, --include attachments the attachment texts. Another feeder's source and an unknown id give the same not-found.

groundnote sources add --scope acme --body mail.json
groundnote sources get "mail:<2026-11-04.abc@example.org>" --scope acme --json

scopes, status#

groundnote scopes [--json]   → the `scopes` array of GET /v1/status
groundnote status [--json]   → GET /v1/status

The grant's scopes with counts and lock state; status adds instance, version, stages and the path-free check summary (checks, architecture section 6).

export#

groundnote export <scope> [--out <file>] [--json] — unchanged (architecture 4.4, D71): the complete scope as .tar.gz for a person with decide, written wx, mode 0600. --json: {scope, file, bytes, sha256}.

approvals open#

groundnote approvals open [--rule <rule-id>] [--json] — prints <url>/approval or <url>/approval/rules/<id> (stdout, bare; --json: {"url": "…"}). No request is made and no browser is started; the person decides in the browser. This is the only CLI path towards approval, by design.

5. Ops commands (ops socket)#

They read GROUNDNOTE_DATA_DIR and GROUNDNOTE_OPS_TOKEN and send the token only after checkOpsSocket has accepted the socket (architecture section 11). They run as the user that owns the data directory, on the host or with docker exec. A missing or short GROUNDNOTE_OPS_TOKEN, the removed GROUNDNOTE_OPS_PORT, and an ops.sock path longer than the platform's sun_path (104 bytes on macOS, 108 on Linux, null byte included; ops-socket-path-too-long, the same refusal as at service start) are exit 1 with that code; the token is not sent.

scope create#

groundnote scope create <name> [--json] → POST /ops/scopes. Unchanged.

grants list, grants add, grants disable#

groundnote grants list [--scope <s>] [--all] [--json]
groundnote grants add --kind person --email <email> --scope <s>... --rights read,propose,decide [--label <l>] [--json]
groundnote grants add --kind service [--access-common-name <cn>] --scope <s>... --rights read,propose [--label <l>] [--json]
groundnote grants disable <grant-id> [--json]

grant add|disable are renamed to grants … (no alias, D97); groundnote grant … exits 2 with the message renamed: use groundnote grants …. list shows active grants (--all includes disabled ones) with id, kind, auth, identity (e-mail or Access common name; a bearer grant shows a fingerprint, never a token), scopes, rights, label and dates. add --kind service without --access-common-name prints the new bearer token on stdout once (--json: inside {id, token}).

groundnote grants add --kind service --scope acme --rights propose --label "mail feeder" > feeder.token
groundnote grants list --scope acme
groundnote grants disable service:3

backup run, backup probe, backup nightly, backup verify#

groundnote backup run [--json]
groundnote backup probe [--scope <s>] [--json]
groundnote backup nightly [--json]
groundnote backup verify [--scope <s>] [--json]
  • run → POST /ops/backup/run. copy: the service writes a consistent copy of ops.sqlite and then of every created scope into backup.directory (architecture 11.1). litestream: nothing to copy (Litestream replicates continuously); exit 0 with that message. A failed copy is exit 1.
  • probe → POST /ops/backup/probe: the daily restore drill (architecture 11.2): restore the newest backup into a throw-away directory through the real restore path, compare it with live, write the nightly Markdown export if backup.export_directory is set. A failed check is exit 3.
  • nightly = run, then probe, then a GET to backup.deadman_url, only if both succeeded. A failed run skips the probe and exits 1; a failed probe exits 3; a failed ping exits 1 (deadman-failed). In every failure case no ping is sent, so the dead-man switch alerts. --json: {ok, run: RunAnswer, probe: ProbeAnswer | null, deadman: "sent" | "failed" | "not-sent"}.
  • verify → POST /ops/backup/verify, unchanged.

The nightly export archive is plaintext (like export); it holds rejected rules and the e-mails of deciding persons. Keep export_directory on the encrypted volume and inside the backup run (restic). Encryption to a per-scope key follows with slice R5 (redaction.md §9).

restore#

groundnote restore --to <path> --scope <s> [--from litestream|copy] [--json] — unchanged (architecture section 11, needs no service).

6. groundnote doctor#

groundnote doctor [--json]

Checks the installation and prints one finding per check. It reads files read-only, asks the running service over the ops socket (GET /ops/status) and the API (GET /health, GET /v1/status). It never writes, never prints a token, and never sends the ops token to an untrusted socket. Exit 0 without fail, 3 with any fail, 1 only if no report can be produced at all (an unexpected internal error), 2 wrong usage. A broken groundnote.json is a fail finding, not exit 1: diagnosing it is doctor's job.

A host is where GROUNDNOTE_CONFIG or GROUNDNOTE_DATA_DIR exists. Without either, the host checks are skip ("no Groundnote host here") and only the client checks run.

const Finding = z.object({
  id: CheckId,                                       // the ids below, stable API
  status: z.enum(["ok", "warn", "fail", "skip"]),
  message: z.string(),                               // may name local paths; never a token
  remedy: z.string().nullable(),                     // required for warn and fail
});
const DoctorReport = z.object({ ok: z.boolean(),     // no fail
  at: Instant, cli_version: z.string(), checks: z.array(Finding) });

Each check is a pure function with typed input in src/ops/doctor/checks.ts (no I/O); the gatherers (stat, statfs, spawn, HTTP) are separate, injectable modules. System programs (df, diskutil, fdesetup, zfs) run from fixed absolute paths with only PATH=/usr/bin:/bin:/usr/sbin:/sbin and LANG=C in their environment, never with a token; a missing program makes the check undetectable, not a crash. Text from the service enters a message only as an error code matching ^[a-z0-9-]{1,64}$ or a version matching ^[0-9A-Za-z.+-]{1,64}$, else as unexpected value. The service reuses the same functions for the path-free checks of /v1/status.

Id ok if warn if fail if skip if
config-file groundnote.json parses (strict schema) and is a regular file owned by the user or root, not writable by group or others; cli.json, if present, parses, is a regular file of the user with mode 0600 and has no credentials-ambiguous — anything else (a writable config lets others redirect backup.directory; a cli.json not 0600 is refused by the CLI itself, so it is fail, not warn) no host and no cli.json
data-dir no symlink, owner = euid, mode exactly 0700, and ops.sock under the data dir's real path fits the Unix socket path limit (104 bytes on macOS and BSD, 108 on Linux, NUL included) — anything else (a too long path is also a named start refusal, ops-socket-path-too-long) no host
data-files every entry: directories 0700, files 0600, ops.sock a socket 0600, owner = euid — any group/other bit or foreign owner no host
stale-temp no .verify-*, .probe-*, .<file>.restore-* in the data dir (and scopes/) and no .<file>.tmp-<uuid> (the temporary name of every copy and export write) in backup.directory (and scopes/) or backup.export_directory (and its <scope>/ folders) older than 1 h — one exists no host
disk-space free space on the data dir's file system (statfs) ≥ 10 % and ≥ 1 GiB free < 10 % or < 1 GiB free < 2 %, < 256 MiB, or < twice the largest SQLite file in the data dir (verify and probe need room for a snapshot and a fetched copy) no host
at-rest Linux: the data dir's block device or a parent in slaves/ has dm/uuid starting CRYPT-, or ZFS encryption ≠ off; macOS: the data dir's volume (mount point from df -P) is the boot or data volume and fdesetup isactive exits 0, or another volume whose diskutil info -plist says FileVault or Encrypted undetectable (container, other fs) and at_rest.attested_at ≤ 365 days old detected and unencrypted; undetectable without at_rest or with an older attestation no host
ops-socket checkOpsSocket accepts ops.sock and GET /ops/status answers (200, or 401 for a rejected token, reported by ops-token) — ops-socket-untrusted; ops-unreachable (service not running); an answer of unknown shape no host; no valid GROUNDNOTE_OPS_TOKEN (not asked, see ops-token)
ops-token GROUNDNOTE_OPS_TOKEN set, ≥ 32 characters, and GET /ops/status is not 401 — missing, short, or rejected no host; socket untrusted (the token is not sent)
backup-age copy: for ops.sqlite and every created scope, the older of the recorded time (ops_backup_at, backup_state.last_backup_at) and the copy's mtime is younger than doctor.max_backup_age_hours (26) litestream (replication lag is not visible) older, never run, copy missing, or a recorded time that does not parse no host
restore-probe every created scope: newest probe passed and probed_at younger than doctor.max_probe_age_hours (26) — failed, too old, or never run no host; service unreachable
api-credentials a credential source exists, url https (http only loopback), GET /health answers service: "groundnote", GET /v1/status with the api credential is 200; an access_jwt has an exp in the future (decoded, not verified) access_jwt expired or expires within 1 h url not https; /health unreachable; credentials-ambiguous; api credential rejected (401/403) no credentials configured
version CLI version equals the service version (/ops/status or /health) they differ (a service version outside ^[0-9A-Za-z.+-]{1,64}$ is shown as unexpected value) — neither reachable
stage-b-model every file of the configured model under <model_dir>/<repo>/ has its shipped SHA-256 — no files of the model in model_dir; a file missing or with another checksum (remedy: groundnote model fetch --model <id>) stage b disabled; no host; groundnote.json unreadable
stage-c-budget spent below 80 % of monthly_cap_usd no scope with reranker: true; spent ≥ 80 % of the cap a scope with reranker: true without typesafe in providers_allowed stage c disabled; service unreachable; groundnote.json unreadable

Stage checks (keys of search/stages-b-c.md §8, snake_case in groundnote.json): search.semantic = {enabled, model: "bge-m3" | "qwen3-embedding-0.6b", model_dir} (model_dir relative to the data dir, default models), search.reranker = {enabled, provider: "typesafe", model: "jev-1.14.0", monthly_cap_usd: 5, timeout_ms: 1000, threshold: 0.5}, per scope scopes[].reranker (default false). Spending comes from ai_usage in ops.sqlite through spentThisMonthUsd(ops, now, zone) (src/search/rerank/budget.ts), which the service reports as usage.reranker_month_usd in GET /ops/status; doctor never opens ops.sqlite itself. The model files are hashed on the host (DoctorSystem.sha256, read in chunks) against the pinned table of src/search/semantic/models.ts.

  • checkStageBModel({semantic, files, expected}): disabled → skip; no files of the model in model_dir or a file's SHA-256 differs from the pinned checksum → fail; else ok.
  • checkStageCBudget({reranker, scopes, spentUsd}): disabled → skip; a scope with reranker: true whose providers_allowed lacks the provider → fail; enabled but no scope with reranker: true → warn; spent ≥ 80 % of monthly_cap_usd → warn ("stage c answers budget when the cap is reached; stage a still answers"); else ok. spentUsd comes from GET /ops/status (the service owns ops.sqlite); service unreachable → skip.

Checks added by the redaction slices (redaction.md §11: secure-delete, redaction-*, key-dir, scope-keys, backup-encrypted) join this table when those slices land.

groundnote doctor
groundnote doctor --json | jq '.checks[] | select(.status == "fail")'

7. Client-side validation and imports#

The CLI validates with the server's schemas, imported from schema-only modules, so the CLI and the service cannot drift: src/domain/schemas.ts, src/domain/source-schemas.ts (new: moved SourceRequest, AttachmentIn, Segment out of domain/sources.ts), src/domain/proposal-schemas.ts (new: ProposalRequest, ProposalSource out of domain/proposals.ts), src/search/schemas.ts, src/graph/schemas.ts (with LINK_TYPES and PROPOSABLE_TYPES moved from graph/links.ts to the new graph/link-types.ts). Entity types are checked only for shape client-side; the configured list is checked by the server. Architecture test AT6 (architecture section 2) keeps writers and the decision path out of the CLI.

8. Nightly run and timers#

One timer runs groundnote backup nightly --json once a day as the data directory's owner, with GROUNDNOTE_DATA_DIR, GROUNDNOTE_CONFIG and GROUNDNOTE_OPS_TOKEN set. 03:30 Europe/Berlin exists exactly once on both DST change days (02:00–03:00 is skipped in March and repeated in October). With the 26 h limits a run may slip by up to two hours without a doctor failure.

File (to be added) What
examples/systemd/groundnote-nightly.service Type=oneshot, User=groundnote, UMask=0077, EnvironmentFile=/etc/groundnote/ops.env (root:root, mode 0600: systemd reads it as root before switching to User=; holds the three variables), ExecStart=/usr/local/bin/groundnote backup nightly --json, After=groundnote.service (no Requires=: a stopped service must fail the run, not start it)
examples/systemd/groundnote-nightly.timer OnCalendar=*-*-* 03:30:00 Europe/Berlin (check with systemd-analyze calendar; without zone support the host zone applies), RandomizedDelaySec=15min, Persistent=true, WantedBy=timers.target
examples/launchd/local.groundnote.nightly.plist StartCalendarInterval Hour 3 Minute 30 (host zone), ProgramArguments /bin/sh -c 'GROUNDNOTE_OPS_TOKEN="$(security find-generic-password -s groundnote-ops -w)" exec /usr/local/bin/groundnote backup nightly --json' (the token stays in the Keychain, not in the plist), EnvironmentVariables for data dir and config, StandardOutPath/StandardErrorPath under ~/Library/Logs/

In a container, run the timer on the host: docker exec groundnote groundnote backup nightly --json. The JSON report on stdout holds paths, counts and hashes, never content or tokens, so the journal or log file may keep it.

Auf GitHub bearbeiten