# logging.md Extensive debug logging for tai, behind `--debug`. One `DebugLog` owns three outputs: a sink (file, stdout, stderr), the vault `logs` table, and nothing else. `LoggingDocTests` fails when this file drifts from the code. ## Flags and sinks `--debug` takes an optional sink: `--debug` alone means `--debug file`, `--debug stdout` and `--debug stderr` redirect the stream. `--debug-format` is `sentence` (default) or `json`. `TAI_DEBUG` and `TAI_DEBUG_FORMAT` are env fallbacks when the flags are absent. Without either flag or env var, logging is fully off: no file, no rows, no overhead beyond one boolean check per hook. The file sink is `/debug.log`, one line per event, flushed on every write so a crash loses nothing. File mode also writes every event to the `logs` table when a store exists; modes without a store (demo, mplex client, grr client) are file-only. stdout/stderr sinks still write the table too: the sink only moves the human stream. ## Wipe rule The log file is wiped on the first write of a root process, and a process is root when `TAI_CHILD` is unset. A child tai (currently the `CapAnswerer` research spawn, which sets `TAI_CHILD=1`) appends to whatever is there and never wipes. In-process workers (fork, scheduler) share the open handle, so they neither wipe nor reopen. Wipe-on-first-write, not wipe-on-boot, so `--version` and other instant exits never destroy the previous log. Two concurrent roots on one home are last-wipe-wins, documented, not prevented. ## Formats Both formats carry the same fields: `ts` (UTC ISO), `level`, `run` (per-process hex id, correlates everything one boot wrote), `profile`, `bot`, `chat` (telegram chat id, else empty), then the dotted event name, the message, and the meta map. Sentence (default), free text last so the head splits cleanly: ```text 2026-10-18T12:01:02Z INFO run=ab12cd34 profile=work bot=main chat= component.event k=v k="quoted value" :: message ``` Meta values containing whitespace or quotes are double-quoted with backslash escapes; everything before ` :: ` is whitespace-split `key=value`, nothing else. JSON is one object per line with sorted keys: ```json {"bot": "main", "chat": "", "component": "agent", "event": "turn_end", "level": "INFO", "message": "turn finished", "meta": {"exec_ms": 12340, "steps": 4}, "profile": "work", "run": "ab12cd34", "ts": "2026-10-18T12:01:02Z"} ``` ## Vault table ```sql logs(id INTEGER PRIMARY KEY, profile, bot, chat, ts, run, level, component, event, message, meta_json) ``` Every filter column is indexed: `idx_logs_profile(profile, id)`, `idx_logs_bot(bot)`, `idx_logs_chat(chat)`, `idx_logs_level(level)`, `idx_logs_event(component, event)`, `idx_logs_run(run)`. An FTS trigger indexes each row under `kind='log'` (`trg_logs_ai`, and `trg_logs_ad` removes pruned rows), so `/search` and `fts_search` rank log lines like any other vault text. `message` and `meta_json` are sealed with `tai_enc` whenever the vault seal is enabled, and unsealed only through `Store.log_query`, which takes the same filters as the indexes plus `limit` and returns newest first. The logger opens its own SQLite connection (WAL, 30s busy timeout, one lock for all threads) instead of sharing `Store.db`, commits every row (so the logger never holds a write transaction that could stall the main store connection, and readers never lag), and prunes to `LOG_DB_KEEP` newest rows every `LOG_DB_PRUNE_EVERY` inserts plus once per configure. Logging never raises: a failed write is counted in `dropped` and skipped. ## Caps | Cap | Value | Meaning | |---|---|---| | `LOG_DB_KEEP` | 20000 | newest log rows kept per vault; older pruned with their FTS rows | | `LOG_DB_PRUNE_EVERY` | 256 | inserts between prune checks | | `LOG_MSG_MAX` | 2000 | message truncation, chars | | `LOG_TEXT_MAX` | 4000 | each trapped text (system/user/assistant) in DB meta, chars | | `LOG_PREVIEW` | 300 | each trapped text in the file sink, chars | | `LOG_META_MAX` | 16384 | `meta_json` cap, bytes; oversized metas shrink longest strings first, so the stored JSON stays valid | ## The rules R1. One event per interesting action, at exactly one site. No action logs twice: `Tools.dispatch` covers all 61 tools, so no `run_*` method logs; the bridge, the scheduler watcher, and the serve dispatcher each log once for their layer. R2. Level follows consequence, not volume. `INFO` for actions taken, `WARNING` for retries and refused auth, `ERROR` for failed operations and uncaught exceptions, `DEBUG` for durability mutations (record/secret/schedule writes). There is no TRACE: anything worth trapping is worth INFO. R3. Every event carries the execution context it ran in: profile, bot, chat (telegram only, else empty), and the process run id. Callers pass what they know; the logger merges its sticky defaults under explicit arguments. R4. LLM completions trap the full accounting tuple: backend, model, `exec_ms`, `tokens_in`, `tokens_out`, `tokens_total`, `cost_usd`, message char counts, the system, user, and assistant texts (full to `LOG_TEXT_MAX` in the DB, previewed to `LOG_PREVIEW` in the file), and the tool census: how many schemas were actually sent, split into MCP, core, and lazy-native, plus the sorted name list. Tokens are estimated at 4 chars each unless a backend reports real usage (none do today); `cost_usd` is null until a backend reports cost. R5. Timed operations always log `exec_ms` as an integer. The measurement wraps the operation, never the logging. R6. Secret values, passphrases, keys, tokens, and header values never reach a log. Names are fine (`secret_saved name=wifi`). Every trapped text passes through `Store.redact` for its profile before truncation, so a known value lands as `[redacted:NAME]`; tool arguments whose key names a secret (`LOG_MASKED_ARGS`) are masked to `***` before logging, which also covers values that are not stored yet and therefore unknown to `redact`. R7. Errors log where they are decided, with `short_error` text: backend failure at the raise site, tool failure at dispatch, auth refusal at the check, uncaught exceptions via `sys.excepthook`. A catch that only re-raises or translates never logs. R8. Hot paths never log: TUI render, input, and compositor paths, token estimators, `redact` itself, FTS internals, health records, and everything inside `DebugLog`. A hook in a per-keystroke or per-frame path is a bug, not coverage. R9. The logger never raises, never blocks on the network, never touches a tool, and never calls back into logged code. A logging failure is a dropped event, counted, not an error event. R10. Child processes append, roots wipe (see Wipe rule). Log volume is bounded by the caps above, never by sampling: pruning deletes oldest first, deterministically. R11. Sentence format keeps free text after ` :: ` and quotes any meta value containing whitespace. JSON format sorts keys. Both are line-delimited and greppable without a parser. R12. New events extend the registry below with a fixed level, a single site, and a fixed meta vocabulary (`exec_ms`, `tokens_in`, `tokens_out`, `tokens_total`, `cost_usd`, `outcome`, `error`, `steps`, `tools_sent`, `tools_mcp`, `tools_core`, `tools_lazy`, `tool_names`, `*_chars`, `*_text`). A schema counts as MCP when its name is not a native `TOOL_SCHEMAS` name. Synonyms are rejected in review. ## Event registry | Event | Level | Site | Meta | |---|---|---|---| | `app.boot` | INFO | `boot()` end | `version`, `home`, `mode`, `profile` | | `app.exception` | ERROR | `sys.excepthook` | `error` | | `agent.turn_start` | INFO | `run_turn` wrapper | `goal`, `capture`, `max_steps`, `depth`, `routed` | | `agent.turn_end` | INFO | `run_turn` wrapper | `exec_ms`, `steps`, `tools_used`, `outcome`, `reply_chars` | | `agent.verified` | INFO | `ensure_verified` end | `success`, `failures` | | `agent.compact` | INFO | `compact_messages` end | `before_tokens`, `after_tokens`, `kept_turns` | | `llm.completion` | INFO | `try_candidate` success | `backend`, `model`, `exec_ms`, `tokens_in`, `tokens_out`, `tokens_total`, `cost_usd`, `history_msgs`, `system_chars`, `user_chars`, `assistant_chars`, `system_text`, `user_text`, `assistant_text`, `tool_calls`, `retried_without_tools`, `tools_sent`, `tools_mcp`, `tools_core`, `tools_lazy`, `tool_names` | | `llm.failed` | WARNING | `try_candidate` failure | `backend`, `model`, `error`, `transient` | | `llm.round_retry` | WARNING | `complete()` before sleep | `round`, `pause_s`, `candidates`, `errors` | | `llm.fatal` | ERROR | `complete()` wrapper on `BackendError` | `error` | | `tool.call` | INFO | `Tools.dispatch` | `name`, `args`, `exec_ms`, `outcome`, `error`, `result_chars` | | `approval.decision` | INFO | `ask_approval` wrapper | `command`, `decision`, `mode` | | `vault.record_saved` | DEBUG | `add_record` end | `id`, `kind`, `size` | | `vault.record_deleted` | DEBUG | `delete_record` end | `id` | | `vault.secret_saved` | DEBUG | `save_secret` end | `name` | | `vault.secret_deleted` | DEBUG | `delete_secret` end | `name` | | `vault.schedule_saved` | DEBUG | `add_schedule` end | `id`, `name` | | `vault.schedule_removed` | DEBUG | `remove_schedule` end | `id` | | `vault.seal_rotated` | INFO | `rotate_seal` end | `home` | | `vault.schema_upgraded` | INFO | `boot()` after print | `notes` | | `sched.fired` | INFO | `scheduler_tick` after spawn | `id`, `profile`, `every_sec` | | `sched.finished` | INFO | `watch_schedule` end | `id`, `status`, `result_chars` | | `serve.request` | INFO | `dispatch_message` | `method`, `tool`, `session`, `exec_ms`, `outcome` | | `serve.denied` | WARNING | `do_POST` auth check | `path` | | `cap.run_start` | INFO | `cap_run_main` head | `sessions` | | `cap.turn` | INFO | `CapFleet.ask` | `target`, `payload_chars`, `exec_ms`, `reply_chars`, `outcome` | | `telegram.update` | INFO | `handle_telegram_update` wrapper | `chat`, `text_chars`, `reply_chars`, `voice`, `outcome` | Outcomes are from a fixed set: `ok`, `error`, `denied`, `unknown_tool`, `bad_args`, `timeout`. `goal`, `args`, `command`, and all `*_text` values are redacted then truncated; `*_chars` always reports the pre-truncation length. ## Validation against the source Every registry site was checked before implementation: `boot`, `run_turn`, `ensure_verified`, `compact_messages`, `try_candidate`, `complete`, `Tools.dispatch`, `ask_approval`, `add_record`, `delete_record`, `save_secret`, `delete_secret`, `add_schedule`, `remove_schedule`, `rotate_seal`, `scheduler_tick`, `watch_schedule`, `dispatch_message`, `do_POST`, `cap_run_main`, `CapFleet.ask`, and `handle_telegram_update` all exist with the shapes the hooks assume. Adjusted during review: serve logging moved from `do_POST` (seven early returns) into `dispatch_message` plus one auth line; scheduler completion moved from `poll_agent` (shared, would double-log forks) into `watch_schedule`; uncaught exceptions moved from a `main()` wrap into `sys.excepthook`; `run_turn`, `complete`, and `handle_telegram_update` became thin wrappers over renamed inners so their many exits log once; `ask_approval` instead logs through an inline `decide()` closure, because `TuiChatTests` calls it unbound on a stub that a rename would break. ## Code map `LOG_*` caps pin the bounds; `DebugLog` owns sink, format, run id, wipe-on-first-write, the DB connection, prune, and redact; module-global `LOG` is the one logger, inert until `configure()`; `TAI_CHILD` selects append; `LOG_MASKED_ARGS` masks secret-shaped tool arguments; `Store.log_query` reads rows back seal-aware. `CapAnswerer.research` marks its child. ## Test hooks `LoggingDocTests` asserts this file names every `LOG_*` cap, every registry event, the `logs` table with its columns and indexes, the `trg_logs_*` triggers, both flags, both formats, and `TAI_CHILD`, with no em dash. `LoggerTests` covers formats, wipe/append, redaction, prune, seal, `log_query`, the never-raise rule, and representative hooks (dispatch, completion, approval, turn).