Architecture Decision Records
July 21, 2026 · View on GitHub
One line per decision, per SPEC §7 rule 3.
- 2026-07-04 · Persist models as JSON blobs (
datacolumn) plus a few key/index columns; add real columns only when queries demand them — simpler than full relational mapping. - 2026-07-04 · PROGRESS.md commit hashes are backfilled in the following step's commit (hash unknowable before committing).
- 2026-07-04 · POLICY.md grammar kept tiny:
- topicunder "Muted topics",- <glob> -> <route>under "Rules"; "Learned" section is prose, never parsed for routing. - 2026-07-04 · Embeddings are pluggable (
core/embedding.pyProtocol); default is a dependency-free hashed bag-of-words vectorizer, real sentence-transformers model wired at Step 9. - 2026-07-04 · POLICY parser lives in
core/policy.py(thepolicy/dir holds only user-facing templates per §6). - 2026-07-04 · Stage-1 rules evaluated in exact SPEC order: quiet hours → muted → dedup → zero-info → policy rules.
- 2026-07-04 · Delivery levels ordered terminal < desktop < silent < vibrate < ring; policy-table "silent push"=silent, "ring"=ring.
- 2026-07-04 · Calendar provider uses a minimal built-in ICS parser (DTSTART/DTEND/SUMMARY); no gcal API in v1 core.
- 2026-07-04 · Scene-threshold overrides live under a "## Scene thresholds" POLICY.md section,
- <scene> = <float>. - 2026-07-04 · scene_cost defaults to 0.0 (scene tolerance already lives in per-scene interrupt thresholds); the cost term stays in the formula and Decision for auditability, configurable later.
- 2026-07-04 · Default topic weights: 0.2 per dimension (score = mean of the 5 components).
- 2026-07-04 · dispatchable && route∈{interrupt,digest} sets route="dispatch"; delivery still happens after the task completes (SPEC §4.4 arrive-with-a-plan).
- 2026-07-04 · Stage-1 drop rules (muted/dedup/zero-info) now run BEFORE the quiet-hours digest rule — §4.7 events 1/24 prove noise must die at night too, not resurface in the morning digest.
- 2026-07-04 · "Interrupted exactly once" counts interrupt-level deliveries (user decision requested, demo #16); a post-dispatch silent FYI (#9) counts as handled, not an interruption.
- 2026-07-04 · Demo digest pool flushes at fixture-marked digest moments (morning at #3, evening at #20); items after 18:30 wait for the next digest.
- 2026-07-04 · §12 name check: agent-chief, chiefd, cortexd all free on PyPI (404); kept
agent-chiefto match the existing repo/remote. - 2026-07-04 · review(phase1): demo anchor #3 moved to 08:00 exactly; morning digest now carries 4 overnight items; event #2 rewritten as a pre-8am zero-info drop to keep the timeline monotonic.
- 2026-07-04 · Judge prompt sent as system+system+user messages (stable system / daily context / per-call user) to maximize provider prompt caching; DeepSeek adapter subclasses the OpenAI adapter (API-compatible).
- 2026-07-04 · Judge backend factory lives in judge/factory.py; "fixtures" is a selectable backend so the demo path needs no special-casing.
- 2026-07-04 · sentence-transformers is an optional extra (
uv sync --extra embeddings) souvx chief demostays torch-free (<60s wow, Principle 1); make_embedder() degrades to HashEmbedder with a warning. - 2026-07-04 · Stage-2 "route by historical same-class mean" = majority route among engaged records above 0.88 similarity, nearest-first tie-break.
- 2026-07-04 · engaged-similar beats dismissed-similar when both fire (spec: drop only "with no engaged record").
- 2026-07-04 · Telegram uses the Bot HTTP API directly over httpx (already a dep) instead of python-telegram-bot — no framework needed for sendMessage + getUpdates, and MockTransport makes it fully testable.
- 2026-07-04 · Button→signal mapping: [Do it]→acted, [Later]→read (weak positive ack), [Mute this kind]→muted.
- 2026-07-04 · "vibrate" delivery maps to a Telegram silent message (disable_notification=true); true vibrate-only needs a mobile OS hook that v1 doesn't have.
- 2026-07-04 · EMA semantics: positive signals pull topic weights toward the event's component vector, negative signals decay toward zero; per-dim clamp [0.02, 0.5].
- 2026-07-04 · Global threshold tuning stores a single additive adjustment; effective threshold = clamp(scene threshold + adjust, 0.35, 0.95).
- 2026-07-04 · Dispatch propensity per (executor, topic) stored in topic_weights under reserved key "dispatch::
:: ", EMA α=0.2 toward 1 on task_ok / 0 on task_fail. - 2026-07-04 · Shadow ✓/✗ grades recorded as feedback signals shadow_good/shadow_bad; shadow gating counts ALL feedback rows toward the 50-sample graduation.
- 2026-07-04 · Shadow start marker persisted in topic_weights under reserved key "shadow".
- 2026-07-04 · Chief home is ~/.chief, overridable via CHIEF_HOME (tests, service units).
- 2026-07-04 · Shell tasks encode their goal as JSON {"template", "args"}; templates are argv lists with placeholder slots filled as single argv elements (no shell=True anywhere), whitelist is query-only.
- 2026-07-04 · acceptance_cmd runs via shlex.split + exec (no shell interpretation); it is operator-configured, not agent-generated, so it does not violate the §13 arbitrary-shell ban.
- 2026-07-04 · Verification with no verifier configured fails closed ("done is a claim, not a proof").
- 2026-07-04 · Task status "rejected" = failed verification/attempts exhausted and handed to the human.
- 2026-07-04 · On dispatch rejection (attempts exhausted), the ask-the-human text is delivered as the message's plan slot — the interrupt itself becomes the escalation.
- 2026-07-04 · Brain.process assembles the full §4.2 loop (normalize → 24h-dedup → stage1 → stage2 → associate → judge → route → persist+audit); webhook and MCP both call it, so every entry point shares one pipeline.
- 2026-07-04 · MCP digest/policy tools are thin v1 wrappers (queue status / file show+append); full digest rendering lands in Step 21.
- 2026-07-04 · RSS parsed with stdlib ElementTree (RSS 2.0 + Atom, title/link/guid only) — no feedparser dependency.
- 2026-07-04 · Poller.tick(now) is the testable unit (interval discipline via injected clock); Poller.run wraps it with a 30s wall-clock heartbeat.
- 2026-07-04 · Resident-process assembly lives in cli/runtime.py;
chief run --once(hidden) assembles everything and exits — the smoke-testable startup path. - 2026-07-04 · Wizard never clobbers an existing POLICY.md/USER.md (manual edits are sacred, Principle 3); config.toml is regenerated on each init.
- 2026-07-04 · Default judge backend is "fixtures" when no ollama is detected and the user skips key entry — chief stays functional (demo-grade) with zero keys.
- 2026-07-04 · review(phase3): Brain now persists a pending Task (dispatch_task_id) when routing to dispatch; an optional fire-and-forget
actorhook performs delivery/dispatch so every entry point (webhook/MCP/pollers) shares the act-on-decision path. - 2026-07-04 · review(phase3): triage merge wired into Brain.process — a same-topic near-duplicate within 10 min folds into the earlier event and inherits its decision.
- 2026-07-04 · Dispatch executor for judge-flagged tasks defaults to claude_code (configurable); executor choice by propensity is a later refinement.
- 2026-07-04 · Digest builder/distillation live in core/digest.py; digest window is last-24h (per-send bookmark deferred).
- 2026-07-04 · Distillation validates the LLM's line against the required format and falls back to a deterministic heuristic line when the LLM is absent or malformed.
- 2026-07-04 · Scheduler = 30s tick loop with (day, job) dedup set; memory TTL expiry piggybacks on the 03:00 distillation job.
- 2026-07-04 · OpenClaw integration speaks plain JSON files under ~/.openclaw (tasks/ inbox for injection, outbox/ for delivery callback) — no OpenClaw API dependency, symmetric and inspectable.
- 2026-07-04 · Demo GIF pipeline is asciinema+agg (vhs needs ttyd+ffmpeg, unavailable here);
make demo-gifis the reproducible entry point. - 2026-07-04 · review(phase4): wired the two orphaned §4.6 pieces — daily global-threshold tuning now runs in the 03:00 job and its stored adjustment feeds score_and_route; USER.md is loaded into the judge context by the runtime.
- 2026-07-05 · eval/ is a real Python package even though it shadows the builtin name — SPEC v3.1 mandates the eval/golden.jsonl path and builtin eval() is unaffected; shipped in the wheel so
chief evalworks installed. - 2026-07-05 · Golden labels are rule-first and generator-verified against the live pipeline (eval/generate_golden.py fails on mismatch); the fixture backend is the 100% sanity ceiling, real backends measure divergence from it.
- 2026-07-05 · Golden cases reuse the demo fixture schema (Fixture/ReplayEntry + replay()) with a meta first line in the JSONL — one pipeline for demo, regression and capability evals.
- 2026-07-05 · Decision.cost (existing field) now carries the USD judgment cost; the full accounting (stages, tokens, cache split, backend, prompt version) lives in Decision.trace — one JSON blob, no schema migration.
- 2026-07-05 · Judge retries bill into the same usage total (the user pays for malformed-output retries too); usage is set by the transport layer, never trusted from the LLM output.
- 2026-07-05 · Price table (judge/pricing.py) uses published list rates per backend family, not per model — per-model tables can arrive when someone needs them; unknown backends are free.
- 2026-07-05 · Prompt versions are directories (judge/templates/v1/…): copy-dir-to-bump, no in-file version headers; the active version is the PROMPT_VERSION constant and per-judge prompt_version overrides it for eval --compare.
- 2026-07-05 · prompts.SYSTEM_PROMPT/RETRY_PROMPT stay as module constants rendered from the active version at import — call sites and the prompt-cache-stable message layout are unchanged.
- 2026-07-05 · eval --compare with the fixtures backend still writes a report but prints a note that fixtures never read prompts — the mechanics stay testable offline, meaningful diffs need a real backend.
- 2026-07-05 · Degradation is conservative in BOTH directions: while the judge is blind, events that pass stage-1 go to digest — never interrupt (annoyance risk) and never drop (loss risk).
- 2026-07-05 · Degradation state lives under the "degraded" key in the topic_weights kv table (same pattern as shadow/threshold_adjust) so
chief statussees it across processes; recovery clears it on the next successful judgment. - 2026-07-05 · The Brain wraps every judge call in asyncio.wait_for(judge_timeout=60s) and catches all exceptions — HTTPJudge's own timeout/retry stays, this is the outer safety net.
- 2026-07-05 · review(phase5): eval reports fall back to ~/.chief/eval-reports when the package dir is read-only (installed wheel); verified
chief evalandchief demorun from the built wheel via uvx; real-backend eval logged in BLOCKERS. - 2026-07-05 · chief lite reuses the full Brain against an in-memory SQLite state rather than a parallel code path — zero-config safety comes from Step 28 degradation (rules fire, judge absent → digest + degraded), no second routing implementation to keep in sync.
- 2026-07-05 · README metrics come from the deterministic demo replay plus a clearly-labeled cost PROJECTION (stable-prefix token split at DeepSeek list prices) — no invented benchmarks; tests/test_readme_metrics.py fails if the committed numbers drift from
make readme-metrics. - 2026-07-05 · The demo's one interruption is a dispatch delivered at interrupt level, so the metrics script counts route=="interrupt" OR delivery=="interrupt" — matching the demo's own "interrupted exactly once" claim.
- 2026-07-05 · Supersedes three earlier ADRs after the review loop: degradation now lives in a dedicated
metatable (key "degraded", not "degraded" in topic_weights); Brain's outer judge_timeout is 150s so HTTPJudge's full 2×60s retry budget fits inside it; pricing gained a per-model table (ordered substring match) — backend-level rates are only the fallback. - 2026-07-05 · No migration for the pre-meta "degraded" row: v0.1.0 was never published to PyPI (BLOCKERS), so no installed base carries the old key.
- 2026-07-06 · v3.2: natural feedback signals (should/shouldn't_interrupt) use the same EMA machinery with alpha 0.35 — stronger than every inferred signal but still bounded; no separate learning path to keep the system explainable.
- 2026-07-06 · POST /v1/feedback stores feedback even for unknown/expired events (learned=false) — feedback must never be droppable just because retention beat it.
- 2026-07-06 · The console is one static HTML file (vanilla JS, no build toolchain) served by the existing FastAPI app on 127.0.0.1 — same port, same bearer token, zero new dependencies; SPEC §13's hosted-UI ban stands untouched.
- 2026-07-06 · Console task approval authorizes EXECUTION, not trust: dispatch_and_verify still runs the acceptance check, so "done is a claim, not a proof" survives the human click.
- 2026-07-06 · Composio adapter is push-only (their webhook subscription), translated at the boundary into the one ingest protocol; topic families map trigger_slug prefixes (GITHUB_→dev.github., GMAIL_→comms.email., …) with a composio.
fallback so unknown apps still learn per-topic. - 2026-07-06 · chief connect edits config.toml with a tiny in-repo serializer (parse-merge-rewrite, comments not preserved) instead of adding a toml-writer dependency — our schema is flat sections plus one connectors.* level, and the wizard regenerates comments anyway.
- 2026-07-06 · review(v3.2): console ids/task-ids ride escaped data-attributes with delegated listeners (never inline handlers) — Event.id is attacker-shaped, so this closes the stored-XSS→token-theft path; all API auth uses hmac.compare_digest; uvicorn binds 127.0.0.1 explicitly.
- 2026-07-06 · Composio webhook is signature- AND freshness-checked (±5 min, fail-closed) with a 1 MiB body cap before buffering; non-dict trigger data is wrapped, not crashed.
- 2026-07-06 · All feedback surfaces (HTTP/MCP/Telegram/console) go through one core.learner.apply_feedback — validates the signal and learns when event+decision are on hand, else records the row; the two natural signals now learn identically everywhere.
- 2026-07-06 · Console task-approve builds the executor from task.executor (not a hardcoded claude_code), so the query-only shell whitelist and noop/openclaw semantics are honored; acceptance_cmd stays argv-exec (never shell) and is operator-set, not LLM-set — the §13 no-arbitrary-shell invariant holds.
- 2026-07-06 · chief connect refuses (with a backup) to rewrite configs its flat serializer can't round-trip, instead of silently corrupting them.
- 2026-07-06 · Step 37 reward-loop eval reuses the real learner + scorer against a simulated hidden-preference user — the "training" is the existing EMA (natural-feedback alpha 0.35), not a new ML path; the harness only measures, keeping §13 (no heavy ML) intact and the whole thing deterministic/offline.
- 2026-07-06 · review(step37): the reward-loop demo is honest about its ceiling — bounded weights mean preference alone can't push a near-zero-signal event over the interrupt bar (documented in the report + a dedicated test), and the harness only corrects borderline events, which is precisely the job (stage-1 rules handle the obvious). README curve numbers are now pinned by a test since no readme-metrics gate covers them.
- 2026-07-08 · Step 38 cohort benchmark generalizes the single-user reward-loop eval to a committed 100-user dataset (eval/personas.jsonl) generated deterministically from a seed; a test pins the file to the generator so published numbers can't drift. Weights are namespaced per persona (
<id>::<topic>) in one shared State DB rather than 100 separate DBs — keeps the loop identical to production without the file churn. - 2026-07-08 · Step 38 uses a train/eval split: ±1 feedback trains on the topic set, interrupt precision/recall/F1 is measured on a disjoint held-out stream (6 jittered events/topic). It reports a distribution (converged %, median/p90 rounds, F1 before→after, noise-tier table) and states the arithmetic ceiling
s ≥ √(T/5)explicitly — a user converges iff every wanted topic is reachable, an invariant a test enforces. RNG is seeded off the persona index, neverhash(str)(salted per-process), to stay reproducible. - 2026-07-13 · Step 39 ablation eval measures each funnel stage on the golden 200 by disabling it, rather than asserting the three-stage design. −stage-1 events with no fixture judge block get a neutral verdict (all dims 0.5), modelling a judge that lacks the mute/dedup/clock state stage-1 owns — the claim is about inputs the funnel provides, not model quality, and the report says so. Cost is reported as an exact judge-call ratio; the USD column is explicitly nominal (free fixtures judge). Stage-2 is measured on repeat traffic (its actual job) not cold accuracy, because a cache can only "pass" on unseen events by construction.
- 2026-07-13 · Step 40 calibration eval is a view over run_cohort()'s held-out stream (the one offline classifier that makes real errors — the fixtures judge is ground-truth by construction, so it can't be miscalibrated). AUC carries the headline because it's assumption-free (pure rank order); reliability/ECE disclose the score→[0,1] map (÷ len(DIMS)·WEIGHT_CAP = 2.5). Isotonic PAV recalibration is fit on an interleaved half and scored on the held-out half (never split by score — that starves the fit of the high range) and is measurement only, never added to the router (§13: no heavy ML). Exposed eval_scores_before/after on PersonaResult additively so cohort's pinned numbers are untouched.
- 2026-07-13 · Step 41 red-team suite scopes itself to what a stateless offline run can prove (hard-rule guards, score-vs-prose, payload hygiene, argv-only executor, display sanitisation) and explicitly does NOT claim the LLM judge is injection-proof — the architecture puts content-blind guards before the judge precisely so trust doesn't rest on the model resisting persuasion. It surfaced two real gaps, both fixed: (a) terminal delivery rendered untrusted summaries with rich markup enabled + passed raw ANSI — fixed by strip_control() at the delivery chokepoint and rich.text.Text at the terminal; (b) /v1/events validated an untyped dict inside brain.process, so a hostile field escaped as a 500 — fixed with a ValidationError handler returning 422. ValidationError (schema rejection) counts as containment, not a crash.
- 2026-07-13 · Step 42 (cohort v2) learned interrupt pins: when should_interrupt keeps arriving but the EMA weight step < 0.01 (weights have saturated below the scene bar), core.learner escalates to a hard per-topic pin stored in State meta (learned_pins), and core.brain consults it right after stage-1 (before stage-2/judge), forcing interrupt like a policy rule — no judge call. Pins only ever escalate from should_interrupt, so they lift only wanted topics (precision preserved). The pin is content-blind and bounded to one topic, so §13 (no heavy ML) holds. Calibration eval runs run_cohort(pins=False) to keep the score-quality measurement decoupled from the pin override. Cohort convergence 64%→95%; the residual non-converged are exactly the erratic-noise users (noise-limited, not arithmetic).
- 2026-07-19 · Step 43 (cohort v3) pin lifecycle: pins gained removal + decay so they stay a live reflection of intent, not a write-only ratchet. Removal is asymmetric to creation — creation must saturate (repeated should_interrupt until |Δw|<0.01) to overcome EMA inertia, but an explicit should_not_interrupt on a pinned topic removes it on the FIRST signal, because the pin forces an interrupt on every event of its topic so the counter-signal is a direct, unambiguous contradiction; a soft dismissed_fast decays weights but never tears down a hard pin. Decay: each firing refreshes last_fired (brain.touch_pin) and the 03:00 job prunes pins idle > PIN_STALE_DAYS (30), bounding the meta blob. Pin records upgraded bare-ISO-string → {pinned_at, last_fired}; reads normalise via _pin_entry so v2 pins survive the upgrade (legacy last_fired == pinned_at, so an old idle pin prunes correctly).
- 2026-07-21 · Step 44 push pipe generalizes ingest without a new endpoint:
chief pushand the Telegram inbound relay both build the same minimal{source, summary}envelope (ingest.push.push_payload) and feed the existingPOST /v1/events/brain.process— one contract, one funnel, zero new trust surface.push_to_daemonreaches the running daemon (notchief lite) so pushes get persistence + delivery; summary is clamped to the 200-char schema limit at the boundary so an over-long push degrades to a truncated event, never a 422. - 2026-07-21 · Step 44 Telegram inbound is gated to the configured chat_id — a bot is reachable by anyone who finds it, so a message from any other chat is a stranger and is dropped before
brain.process, never ingested. It reuses the already-trusted Telegram transport (not a new relay like ntfy/Pushover) as the off-box inbound path, keeping §13's delivery-surface list unchanged: still a personal device, not a team channel.poll_callbacksgained an optionalprocess=so message handling is additive and the existing callback-button path is untouched. - 2026-07-21 · review(step44) hardening: the Telegram poll task must outlive the network — getUpdates failures (5xx/timeout/non-JSON) retry with capped exponential backoff (1s→60s) instead of silently killing the daemon's only phone pipe; a poison update costs exactly itself (offset advances past the whole batch before handling, failing updates are logged and skipped — no redelivery storm, no dead loop; a restart may replay un-acked updates, which the 24h dedup absorbs). The decision-echo reply is best-effort: once brain.process has decided and persisted, a failed send is logged, never propagated. Push-contract validation lives in push_payload (edge, pre-network): empty summary and unknown urgency raise ValueError with a human message instead of surfacing as an opaque server 422; summaries collapse to one line, and any collapsed/clamped Telegram text keeps its full original in
detail— clamping is a rendering concern, never data loss. - 2026-07-21 · Step 45 outbound protocol: more channels are earned by a receiver contract, not by in-tree adapters — one signed JSON POST ({event_id, topic, summary, plan, level, sent_at}) to a user-configured URL turns any HTTP receiver (phone-app bridge, self-hosted ntfy relay, desktop applet) into a delivery channel. Kept the §13 stance: no Slack/Discord/WeChat adapter ships, no team-channel formatting exists; this is neutral egress to the user's own receiver, the same personal-delivery posture as Telegram. The signature is byte-for-byte the svix scheme the Composio inbound connector verifies (a test proves sign() round-trips through the inbound verify_signature), so the protocol has one verify function in both directions. Unsigned mode exists but warns loudly. Failure semantics are honest: 3 retries with backoff, then raise → brain._act_safely logs the loss; there is no outbound queue, and the docs say so instead of implying durability. Config is one [delivery.webhook] table (url/secret/max_level) written by
chief connect webhook— one receiver for now; a list can come when someone needs it (simpler-option rule). - 2026-07-21 · Step 45 skill transport order: the claude-code skill now teaches
chief pushfirst (full pipeline: persistence, learning, delivery),chief liteas the zero-daemon fallback, raw webhook/MCP last — a skill that judges via lite when a daemon is resident silently forfeits learning and delivery. The openclaw skill stays MCP-propose (MCP-native host). - 2026-07-21 · Step 45 reliability pass: deliver() walks a fallback chain instead of betting the event on one channel — primary pick first (weakest capable), remaining capable weakest-first, then incapable strongest-first; a worthy event lands somewhere (degraded loudness beats a silent loss) and only an all-channels failure propagates. This generalizes the pattern DesktopChannel already used internally (notify fails → terminal). Inbound twin: cancelling the Telegram poll task fires one last offset-only getUpdates (2s hard timeout, best-effort) so a clean shutdown acks the handled batch and a restart never replays it (duplicate feedback rows / re-ingested pushes / reply noise); a crash still replays, and the 24h dedup absorbs that.