dc435f8d71
---ci--- project: inboxos phase: 1 milestone: v1.4 status: execute wave: 3 task: T2 ---/ci---
858 lines
70 KiB
Markdown
858 lines
70 KiB
Markdown
# InboxOS — Operations Runbook
|
||
|
||
**Audience:** Any operator (or the same operator after a 6-month gap) recovering from common failure modes without re-deriving the architecture.
|
||
|
||
**Status:** Phase 5 deliverable for [INB-7](/INB/issues/INB-7) — issued under [INB-35](/INB/issues/INB-35).
|
||
|
||
**Scope:** Single self-hosted pipeline. A Gmail inbox → IMAP poll → SeaweedFS archive + LLM-extracted rows in NocoDB + curated attachments in Papra. Everything is self-hosted; no third-party SaaS touches PII.
|
||
|
||
**Acceptance test for this runbook:** *"Given a workflow is stuck in `in_progress` for 1 hour, what do you do?"* — answer in under 5 minutes using §5.1 below.
|
||
|
||
---
|
||
|
||
## 1. System overview
|
||
|
||
InboxOS is a self-hosted, air-gapped personal ERP/CRM pipeline. It polls a single Gmail inbox over IMAP, backs up the raw `.eml` to SeaweedFS, classifies each email with a local LLM (Ollama), runs one or more `inboxos:extract-<module>` prompts against the same email, and UPSERTs the resulting structured records into NocoDB (one table per module: `finance`, `crm`, `intel`, `promotions`). Qualifying attachments are also uploaded to Papra for long-term searchability. The historical pipeline performs the same flow against a Google Takeout `.mbox` for backfill, with byte-offset checkpoints for safe resume.
|
||
|
||
### 1.1 Topology
|
||
|
||
```
|
||
┌──────────────┐ IMAP/993 ┌─────────────────┐
|
||
│ Gmail │ ◀───────────── │ gitea-runner-1 │
|
||
│ inbox │ (App Password)│ gitea-runner-2 │ (self-hosted, parallel)
|
||
└──────────────┘ └────────┬────────┘
|
||
│ run jobs (Gitea Actions)
|
||
│ polls IMAP, calls Ollama
|
||
▼
|
||
┌──────────────────┐ raw .eml ┌─────────────────┐
|
||
│ Papra │ attachment │ SeaweedFS │ S3-compatible
|
||
│ (long-term doc)│ ◀───────────│ (S3 API) │ .eml archive +
|
||
│ intake │ uploads │ │ state checkpoints
|
||
└──────────────────┘ └────────┬────────┘
|
||
│ UPSERT by Message-ID
|
||
▼
|
||
┌─────────────────┐ direct HTTP
|
||
│ NocoDB │ ── /v1/chat
|
||
│ (DWH) │ /completions
|
||
└─────────────────┘ │
|
||
▲ ▼
|
||
┌─────────────────┐
|
||
│ Ollama │ local LLM
|
||
│ (FRP) │ gemma4:e4b
|
||
│ /v1/chat │
|
||
│ /completions │
|
||
└─────────────────┘
|
||
```
|
||
|
||
- **Gmail** is the source. The runner authenticates with an App Password (2FA must be on).
|
||
- **Gitea** is the version-controlled home for prompts, scripts, and CI.
|
||
- **Gitea Actions runners** (`gitea-runner-1` and `gitea-runner-2`, both self-hosted) execute the live and historical pipelines. Two runners so the 5-minute live cron and a long historical run can execute in parallel.
|
||
- **SeaweedFS** is the system of record for raw `.eml` (in `s3://$SEAWEEDFS_S3_BUCKET/eml/...`) and pipeline state (`state/live-state.json`, `state/historical-state.json`).
|
||
- **NocoDB** is the structured warehouse. Every module table is keyed on `Message_ID` (UNIQUE). All writes are UPSERTs.
|
||
- **Papra** holds curated attachments and (selectively) full email bodies for long-term search. The first ship goes out in **dry-run** mode.
|
||
- **Ollama** serves the local LLM behind a FRP tunnel at `https://ollama-frp.cloudinit.dev/v1`. No external API calls for PII-bearing data.
|
||
|
||
---
|
||
|
||
## 2. Service inventory
|
||
|
||
All services run in the `inboxos-net` Docker network defined in `docker/docker-compose.yml`.
|
||
|
||
| # | Service | Container / DNS name | Public URL | Role in pipeline | Restart | Log location |
|
||
|---|------------------|---------------------------|-----------------------------------------|-----------------------------------------------------------------------------------------------|---------------------------------------------------------------|-----------------------------------------------------------------------|
|
||
| 1 | **NocoDB** | `nocodb` | `https://noco.cloudinit.dev` | ERP/CRM data warehouse. Four module tables (`finance`, `crm`, `intel`, `promotions`). | `docker compose -f docker/docker-compose.yml restart nocodb` | `docker logs nocodb` |
|
||
| 2 | **Gitea** | `gitea` | `https://git.cloudinit.dev` | Version control for prompts, scripts, and CI. Hosts Actions runner registration & secrets. | `docker compose -f docker/docker-compose.yml restart gitea` | `docker logs gitea` |
|
||
| 3 | **Gitea Actions Runners** | `gitea-runner`, `gitea-runner-2` | n/a (self-hosted) | Execute the live and historical workflows. Two so live cron + historical run can run in parallel. | `docker compose -f docker/docker-compose.yml restart gitea-runner [gitea-runner-2]` | `docker logs gitea-runner [gitea-runner-2]` + per-job artifact under `pipelines/_artifacts/*.log` |
|
||
| 4 | **Papra** | `papra` | `https://papra.cloudinit.dev` | Curated document archive (attachments + selected bodies). First ship is dry-run. | `docker compose -f docker/docker-compose.yml restart papra` | `docker logs papra` |
|
||
| 5 | **SeaweedFS** | `seaweedfs-master`, `-volume`, `-filer` | `https://storage.cloudinit.dev` (via reverse proxy) on `:8888` S3 endpoint in-network | Object store for `.eml` archive and pipeline state checkpoints. | `docker compose -f docker/docker-compose.yml restart seaweedfs-{master,volume,filer}` | `docker logs seaweedfs-{master,volume,filer}` |
|
||
| 6 | **Ollama** | `ollama` | `https://ollama-frp.cloudinit.dev/v1` | Local LLM inference for `inboxos:classify` and `inboxos:extract-*`. Default model `gemma4:e4b`. | `docker compose -f docker/docker-compose.yml restart ollama` | `docker logs ollama` |
|
||
|
||
The **OpenCode CLI** is not invoked by either pipeline; both
|
||
pipelines call Ollama's OpenAI-compatible `/v1/chat/completions`
|
||
endpoint directly via `pipelines.common.llm_run_command` (default
|
||
backend: `ollama`). The `opencode` binary install step in
|
||
`historical-pipeline.yml` is retained for any human-driven
|
||
`opencode` invocation from the repo worktree; the pipeline itself
|
||
does not shell out to it. See `docs/extraction-contract.md §Legacy:
|
||
opencode CLI backend` for the rationale.
|
||
|
||
The **reverse proxy** (Caddy in the commented-out `caddy` service in `docker/docker-compose.yml`) terminates TLS for `*.cloudinit.dev` and routes to each service. Operators without a reverse proxy can reach services via the `inboxos-net` Docker DNS names (e.g. `http://gitea:3000`, `http://seaweedfs-filer:8888`).
|
||
|
||
---
|
||
|
||
## 3. Workflow operations
|
||
|
||
All workflows live in `.gitea/workflows/`. They are declarative, version-controlled in the same repo as the scripts they invoke, and have no separate runtime state to back up. This is the board decision recorded in [INB-8](/INB/issues/INB-8) — n8n was ruled out as unreliable.
|
||
|
||
**Quickstart:** for a 15-minute green-field spin-up, see `docs/quickstart.md` and `scripts/quickstart.sh` (REQ-INB-40). The script runs the 8 steps below in sequence and prints the runbook §X.Y cross-ref on any failure.
|
||
|
||
**Daily digest:** see `scripts/daily_digest.py` and `.gitea/workflows/daily-digest.yml` (REQ-INB-47). The workflow runs on `cron 0 6 * * *` and emails a one-line per-module summary.
|
||
|
||
**Container hardening:** all services run with `cap_drop: [ALL]`, `security_opt: no-new-privileges:true`, and either `read_only: true` (no write access needed) or `read_only: false` with a `tmpfs: [/tmp]` mount (for services that need a writable scratch dir). See `docker/docker-compose.yml` for the full stanza and the per-service `cap_add` list (REQ-INB-55).
|
||
|
||
**DPIA:** see `docs/security/dpia.md` (REQ-INB-58). The Data Protection Impact Assessment per GDPR Art. 35 names the data flows, the lawful basis, the data-subject rights, and the residual risks.
|
||
|
||
**Threat model:** see `docs/security/threat-model.md` (REQ-INB-59). The STRIDE-per-component decomposition covers the 10 components in the inboxos architecture.
|
||
|
||
### 3.1 Live pipeline — `live-pipeline.yml`
|
||
|
||
| Aspect | Value |
|
||
|----------------|---------------------------------------------------------------------------------------------|
|
||
| Schedule | `cron(*/5 * * * *)` — every 5 minutes |
|
||
| Manual trigger | `workflow_dispatch` with optional inputs `since_uid` (override high-water mark) and `max_emails` (default 50) |
|
||
| Concurrency | `group: live-pipeline, cancel-in-progress: false` (overlapping runs must not trample state) |
|
||
| Timeout | 15 minutes |
|
||
| Script | `pipelines/live_pipeline.py` |
|
||
| Run log | `pipelines/_artifacts/live-run-<id>.log` (uploaded as `live-pipeline-log` artifact) |
|
||
|
||
**Flow** (verifiable in `pipelines/live_pipeline.py`):
|
||
|
||
1. Load the IMAP UID checkpoint from `s3://$SEAWEEDFS_S3_BUCKET/state/live-state.json`.
|
||
2. Poll IMAP for `UID > last_uid` (bounded by `MAX_EMAILS_OVERRIDE`, default 50).
|
||
3. For each message, in UID-ascending order:
|
||
- Back up the raw `.eml` to `s3://$SEAWEEDFS_S3_BUCKET/eml/live/<yyyy>/<mm>/<dd>/<Message-ID>.eml` (skip if exists).
|
||
- Call `inboxos:classify` to pick which modules apply.
|
||
- For each applicable module, call `inboxos:extract-<module>` and UPSERT the result.
|
||
- Flush the checkpoint (`state/live-state.json`) **per message** so a crash never replays a success.
|
||
|
||
**What success looks like in the run log:**
|
||
|
||
- One `live_run_start` event at the top.
|
||
- One or more `process_start` → `eml_backed_up` → `classified` → `process_done` event groups, in UID-ascending order.
|
||
- Every `process_done` is followed by a checkpoint write (visible as the `state/live-state.json` PUT in S3, or as the next call to `imap_fetched`).
|
||
|
||
**What failure looks like:**
|
||
|
||
- A `classify_error` or `extract_permanent_failure` event means the pipeline stops at that UID. The next run retries it.
|
||
- A `dead_letter_unclassified` event means the email had no module match — the raw `.eml` is preserved at `eml/dead-letter/<mid>.eml` and the cursor advances.
|
||
- A `missing_message_id` event means the email lacked a `Message-ID:` header. The raw `.eml` is preserved at `eml/dead-letter/uid-<n>.eml`.
|
||
- A non-zero job exit code with no `live_run_done` in the log means the runner OOM'd or the IMAP connection was killed mid-fetch — see §5.1.
|
||
|
||
### 3.2 Historical pipeline — `historical-pipeline.yml`
|
||
|
||
| Aspect | Value |
|
||
|----------------|---------------------------------------------------------------------------------------------|
|
||
| Schedule | **None** — manual only (`workflow_dispatch`). Heavy operation; full archives run for hours. |
|
||
| Required input | `mbox_object_key` — the SeaweedFS object key of the `.mbox` (e.g. `eml/historical/all-mail.mbox`) |
|
||
| Optional inputs| `start_offset` (resume from a byte offset; leave blank to use persisted checkpoint), `batch_size` (default 100), `limit` (0 = no limit) |
|
||
| Concurrency | `group: historical-pipeline, cancel-in-progress: false` |
|
||
| Timeout | 12 hours |
|
||
| Script | `pipelines/historical_pipeline.py` (canonical reference for the extraction contract) |
|
||
| Streamer | `pipelines/mbox_stream.py` — byte-offset streaming parser for `.mbox` |
|
||
| Run log | `pipelines/_artifacts/historical-run-<id>.log` (uploaded as `historical-pipeline-log` artifact) |
|
||
|
||
**Idempotency contract:**
|
||
|
||
- **Message-ID UPSERT** is the only write path into NocoDB (`pipelines/common.py::nocodb_upsert`). It does a GET by `Message_ID` and PATCHes if present, POSTs otherwise. Re-running the same `.mbox` produces zero duplicates.
|
||
- **Byte-offset checkpoint** is persisted to `s3://$SEAWEEDFS_S3_BUCKET/state/historical-state.json` every `BATCH_SIZE` messages (default 100) and on shutdown. A crashed run resumes from the last checkpoint on the next dispatch.
|
||
- **Object keys** are deterministic by `Message-ID` and date, so re-uploading the same `.eml` overwrites the same S3 object.
|
||
|
||
**Expected runtime per 10K messages:** ~5–10 minutes on a clean `c5.large` runner when the LLM is the bottleneck (depends on the model — `gemma4:e4b` is fast; `gemma4:12b` adds ~2×). The I/O-bound parts (S3 GET of the `.mbox`, S3 PUTs of `.eml`, Papra uploads) are sub-second per message.
|
||
|
||
**How to resume after a crash:**
|
||
|
||
1. Re-dispatch `historical-pipeline.yml` with the **same** `mbox_object_key` and **no** `start_offset` input. The script loads `state/historical-state.json` and resumes from the persisted byte offset.
|
||
2. Do **not** dispatch with `start_offset=0` unless you have deleted the state file — that re-processes everything from the top (safe but wasteful, and will take hours).
|
||
|
||
**How to dead-letter a message manually:**
|
||
|
||
- A message that was already dead-lettered by the pipeline lives at `s3://$SEAWEEDFS_S3_BUCKET/eml/dead-letter/historical-<offset>.eml` (or `<mid>.eml`). To permanently skip a poison message, find its `Message-ID` in the run log, then write a marker object to `eml/dead-letter/poison/<mid>.json` containing the reason. A future enhancement will let the script consult this list; for now, mark it in your run log and move on.
|
||
|
||
### 3.3 PR CI — `ci-pr.yml`
|
||
|
||
| Aspect | Value |
|
||
|----------------|---------------------------------------------------------------------------------------------|
|
||
| Triggers | `pull_request` opened / reopened / synchronized against `main`; `workflow_dispatch` |
|
||
| Concurrency | `group: pr-ci-${{ pull_request.number }}`, cancel-in-progress |
|
||
| Jobs | `fast-tests` (~30s), `e2e-100mb` (20 min), `live-llm-smoke` (15 min, real Ollama) |
|
||
| Required secrets | `NOCODB_BASE_URL`, `NOCODB_API_TOKEN`, `NOCODB_PROJECT_ID`; `OLLAMA_BASE_URL` is optional |
|
||
|
||
- `fast-tests` exercises `gitea_request`, `mbox_stream`, the offline parts of `live_pipeline`, and the fast subset of `historical_pipeline` (config, parsing, dead-letter paths).
|
||
- `e2e-100mb` builds a 100 MB synthetic `.mbox` and runs the historical pipeline end-to-end with S3 / NocoDB / Papra stubbed. Verifies UPSERT idempotency, crash-and-resume offset stability, and dead-letter semantics. The merge gate.
|
||
- `live-llm-smoke` calls the real Ollama endpoint with the Substack input that previously dead-lettered in live run #674 (UID 4017, 86 KB body) and asserts the extract step returns parseable JSON. Skipped when `OLLAMA_BASE_URL` is not set; surfaces a clear `skipped` notice rather than failing the PR.
|
||
|
||
---
|
||
|
||
## 4. Observability
|
||
|
||
### 4.1 Reading a workflow run log
|
||
|
||
1. Open the Gitea UI → repo → **Actions** → click the run.
|
||
2. The job's `Run process-new-emails` / `Run process-mbox` step shows stdout in the Gitea Actions UI. Each line is a JSON object emitted by `log_event`.
|
||
3. Expand the **Upload run log as artifact** step. The per-run log file is downloaded as `live-pipeline-log` or `historical-pipeline-log`. This is identical to what was streamed to stdout, and is the canonical artifact to attach to a post-mortem.
|
||
4. The artifact also lives in the project workspace at `pipelines/_artifacts/<pipeline>-run-<run-id>.log`.
|
||
|
||
### 4.2 Structured event vocabulary
|
||
|
||
Every step in both pipelines calls `pipelines.common.log_event` to emit a JSON line. The canonical event names (verified in source) are:
|
||
|
||
| Event | Where | What it tells you |
|
||
|--------------------------------|--------------------------------------------|--------------------------------------------------------------------------------|
|
||
| `process_start` | `live_pipeline.py:428`, `historical_pipeline.py:282` | A new message is about to be processed. Carries `uid` (live) or `offset` (historical) and `message_id`. |
|
||
| `eml_backed_up` | both pipelines | The raw `.eml` was PUT to SeaweedFS. Carries `key` and `bytes`. |
|
||
| `classified` | both pipelines | `inboxos:classify` returned a non-empty module list. Carries `modules`. |
|
||
| `llm_call` | `common.py:591` | The Ollama call is about to be made. Carries `backend`, `modelID`, `command`, `url`. |
|
||
| `process_done` | both pipelines | The message was processed end-to-end. Carries `upserts` (NocoDB action list). |
|
||
| `module_table_missing` | both pipelines | A module came back from classify but has no entry in `NOCODB_MODULE_TABLES`. Carries `module`. |
|
||
| `extract_permanent_failure` | both pipelines | `inboxos:extract-<module>` failed irrecoverably. The run stops without advancing. |
|
||
| `dead_letter_unclassified` | both pipelines | Classify returned `[]`. The `.eml` is preserved at `eml/dead-letter/...` and the cursor advances. |
|
||
| `historical_run_done` | `historical_pipeline.py:545` | The historical run finished cleanly. Carries totals. |
|
||
| `live_run_start` | `live_pipeline.py:533` | The live run began. Carries `started_at`. |
|
||
| `papra_decision` | `common.py:997` | The Papra write-through decision was made. Carries `would_write`, `reason`, `module`, `dry_run`. |
|
||
| `papra_write_ok` / `papra_write_failed` / `papra_write_dry_run` | `common.py:1018`-`1055` | Per-target outcome of a Papra write. |
|
||
| `state_recovered_from_corrupt` | `live_pipeline.py:550` | The persisted IMAP checkpoint was malformed; the run reset it. |
|
||
|
||
The `live_pipeline.py` source also emits `imap_search_result`, `imap_fetch_missing_body`, `imap_fetched`, `classify_error`, `extract_invalid_response`, `missing_message_id`, `config_error`, `since_uid_override`, `stopped_on_failure_at_uid`, and others (search for `log_event` in `pipelines/live_pipeline.py` for the full list).
|
||
|
||
### 4.3 NocoDB table inventory
|
||
|
||
The four module tables (verified against `pipelines/_tests/test_upsert_synthetic.py` and `docs/phase-1.1-verification/inb-28-report.md`):
|
||
|
||
| Module | Table name in NocoDB | Table ID | Notes |
|
||
|--------------|----------------------|---------------------|-------|
|
||
| `finance` | `pl_finance` | `m0thluc3jyz2iok` | Receipts, invoices, P&L items. 14 user columns. |
|
||
| `crm` | `personal_crm` | `m2wsbpp01v08pm5` | People, organisations, intent. 15 user columns. |
|
||
| `intel` | `daily_news` | `msi6dj1cany1b5r` | Newsletters, briefings. 13 user columns. |
|
||
| `promotions` | `promotions` | `mf7catfwwyh3jj5` | Sales, promo codes, launches. 14 user columns. |
|
||
|
||
Per-table row counts and column schemas are listed in `docs/phase-1.1-verification/inb-28-report.md` and `docs/schemas.md`. Every module table has `Message_ID` (SingleLineText, UNIQUE, required) as the natural key.
|
||
|
||
### 4.4 SeaweedFS bucket layout
|
||
|
||
`$SEAWEEDFS_S3_BUCKET` is `inboxos` (default in `.env.example`). The layout is:
|
||
|
||
```
|
||
s3://inboxos/
|
||
├── eml/
|
||
│ ├── live/
|
||
│ │ └── <yyyy>/<mm>/<dd>/<Message-ID>.eml
|
||
│ ├── historical/
|
||
│ │ └── <yyyy>/<mm>/<dd>/<Message-ID>.eml
|
||
│ └── dead-letter/
|
||
│ ├── <Message-ID>.eml (live, unclassified)
|
||
│ ├── uid-<uid>.eml (live, missing Message-ID)
|
||
│ └── historical-<offset>.eml (historical)
|
||
└── state/
|
||
├── live-state.json {"last_uid": <int>, "last_run_at": <iso>, "messages_seen": <int>}
|
||
└── historical-state.json {"mbox_key": <str>, "offset": <int>, "messages_seen": <int>, "last_run_at": <iso>}
|
||
```
|
||
|
||
> State-file field names come from the actual code: live
|
||
> (`pipelines/live_pipeline.py:600-603`) and historical
|
||
> (`pipelines/historical_pipeline.py:516-521`). The
|
||
> `_corrupt_state_recovered` field is an additional transient
|
||
> key on first load when the persisted JSON is unparseable.
|
||
|
||
The state files are tiny JSON blobs written via `s3_put_bytes` after every successful message (live) or every `BATCH_SIZE` messages (historical). They are the **only** persistent state between workflow runs.
|
||
|
||
### 4.5 Metrics surface (REQ-INB-23)
|
||
|
||
Each pipeline run writes **one** JSONL line to
|
||
`pipelines/_artifacts/<pipeline>-metrics-<run_id>.jsonl` (one
|
||
file per run; never overwritten). The line shape and counter /
|
||
gauge vocabulary are documented in
|
||
`pipelines/_metrics/emit.py` (a copy-paste of the docstring is
|
||
in this runbook for operator reference).
|
||
|
||
| Counter key | Type | What it tells you |
|
||
|----------------------------|------|------------------------------------------------------------|
|
||
| `messages_processed` | int | Total messages processed in this run. |
|
||
| `messages_dead_lettered` | int | Subset routed to the dead-letter prefix. |
|
||
| `noco_upsert_ok` | int | NocoDB upserts that returned 2xx. |
|
||
| `noco_upsert_4xx` | int | 4xx (drift; permanent failure for the affected message). |
|
||
| `noco_upsert_5xx` | int | 5xx (transient; retried by tenacity). |
|
||
| `papra_would_write` | int | Papra decisions that resolved `would_write=true`. |
|
||
| `papra_write_ok` | int | Papra HTTP POSTs that returned 2xx. |
|
||
| `papra_write_failed` | int | Papra HTTP POSTs that returned >= 400. |
|
||
| `papra_index_upsert_ok` | int | `papra_index` audit-trail rows written. |
|
||
| `papra_index_upsert_failed`| int | `papra_index` upserts that raised. |
|
||
| `llm_call_seconds_sum` | int | Total wall-clock ms spent in `requests.post` to Ollama. |
|
||
| `llm_call_count` | int | Number of LLM HTTP POSTs in the run. |
|
||
|
||
| Gauge key | Type | What it tells you |
|
||
|--------------------------|----------------|-------------------------------------------------|
|
||
| `last_run_at` | ISO 8601 str | The run's `now_iso()` at metrics emission. |
|
||
| `dead_letter_total` | int | Cumulative dead-letter prefix size (best-effort). |
|
||
| `papra_dry_run` | "true"/"false" | The effective `PAPRA_DRY_RUN` after precedence. |
|
||
| `papra_dry_run_override` | "true"/"false"/"none" | The parsed override (or "none" if unset). |
|
||
|
||
**Tail-and-summarize procedure.** To compute a daily total of
|
||
`messages_processed` across all live runs:
|
||
|
||
```bash
|
||
cat pipelines/_artifacts/live-metrics-*.jsonl \
|
||
| jq -s '[.[].counters.messages_processed] | add'
|
||
```
|
||
|
||
To inspect the last 20 metrics lines in time order:
|
||
|
||
```bash
|
||
cat pipelines/_artifacts/live-metrics-*.jsonl \
|
||
| tail -20 \
|
||
| jq -c '{ts, run_id, counters}'
|
||
```
|
||
|
||
**Forward-pointer.** Alerting is **out of scope** for v1.4. v1.2
|
||
closed on a 0/1 ideation disposition (no candidates accepted) per
|
||
`.ciagent/inboxos/CI-LOG.md`, and v1.3 was a doc-hygiene
|
||
milestone; v1.4 added the operator audit cursor and the
|
||
pollable healthcheck but no alert layer. The metrics file is
|
||
the substrate; the alert layer is the next v1.x candidate
|
||
cycle to re-evaluate. In the meantime the runbook §4
|
||
observability surface is logs-only, with
|
||
`pipelines/_metrics/emit.py` emitting a per-run `metrics.jsonl`
|
||
(REQ-INB-23) for offline postmortem and
|
||
`pipelines/_metrics/healthcheck.json` (REQ-INB-52) as the
|
||
canonical uptime-monitor pollable endpoint.
|
||
|
||
**Opt-out.** Set `LLM_METRICS_ENABLED=false` in the pipeline env
|
||
to skip the `time.monotonic()` wrapping around the LLM HTTP POST.
|
||
The counters default to enabled; opt-out is for canary runs that
|
||
want to measure pure LLM latency without the wrapper overhead.
|
||
|
||
---
|
||
|
||
## 5. Common failure modes and recovery
|
||
|
||
### ERROR-NN catalog
|
||
|
||
A one-page lookup table for the on-call operator. If you are reading this from a pager, the table below tells you which §5.X subsection to jump to for the full recovery procedure.
|
||
|
||
| ERROR-NN | Symptom | See §X.Y |
|
||
|-----------|------------------------------------------------------|----------|
|
||
| ERROR-01 | Runner stuck / OOM (> 60 min in `in_progress`) | §5.1 |
|
||
| ERROR-02 | Live cron emitted no new MIDs for > 30 min | §5.2 |
|
||
| ERROR-03 | NocoDB 4xx on upsert (wrong table_id) | §5.3 |
|
||
| ERROR-04 | NocoDB 4xx "field is required" (schema drift) | §5.3 |
|
||
| ERROR-05 | IMAP connection refused / auth failure | §5.4 |
|
||
| ERROR-06 | Ollama connection refused / model OOM | §5.5 |
|
||
| ERROR-07 | SeaweedFS connection refused / auth failure | §5.6 |
|
||
| ERROR-08 | Papra `dry_run` unexpectedly flipped to `false` | §5.7 |
|
||
| ERROR-09 | State file corrupted / offset regression | §5.8 |
|
||
| ERROR-10 | Backup tar fails (NocoDB / SeaweedFS / Gitea) | §5.9 |
|
||
|
||
### 5.1 Runner stuck / OOM (> 60 minutes in `in_progress`)
|
||
|
||
A single stuck job can block the live cron (live jobs have `cancel-in-progress: false`). Diagnosis and recovery:
|
||
|
||
1. **Confirm it is stuck.** Open Gitea Actions → run → check whether the `Run process-new-emails` step has emitted *any* new log lines in the last 10 minutes. A runner that is downloading a large `.mbox` is making progress (watch for S3 GET bytes) — a runner that is silent is OOM'd or wedged.
|
||
2. **Check the host's container health.**
|
||
```bash
|
||
docker ps --filter "name=gitea-runner" --format "table {{.Names}}\t{{.Status}}\t{{.CPUPerc}}\t{{.MemUsage}}"
|
||
```
|
||
Memory at 100% with no progress for > 10 minutes = OOM. CPU at 0% with no progress = wedged.
|
||
3. **Drain the stuck job.** From Gitea UI → run → **Cancel job**. The live workflow's `cancel-in-progress: false` will *not* preempt the next cron tick — the next run will queue until the cancelled one reports back. This is the correct behaviour: do not change the concurrency setting to fix a stuck job.
|
||
4. **Restart the runner.**
|
||
```bash
|
||
docker compose -f docker/docker-compose.yml restart gitea-runner
|
||
```
|
||
If `gitea-runner-2` is also stuck, restart it too. The other runner will pick up new jobs within ~30 s.
|
||
5. **Verify recovery.**
|
||
```bash
|
||
curl -u "$GITEA_USER:$GITEA_PASS" \
|
||
https://git.cloudinit.dev/api/v1/repos/jc-capital/inboxos/actions/runners
|
||
```
|
||
Expect 2 entries with `status: "online"`. Then dispatch `live-pipeline.yml` manually with a small `max_emails` (e.g. `5`) to confirm the end-to-end loop.
|
||
6. **If recurring:** the runner is under-provisioned. Increase its memory limit in `docker/docker-compose.yml` (`mem_limit: 4g` or similar) or move to a host with more RAM.
|
||
|
||
### 5.2 SeaweedFS `NoSuchKey` on a Message-ID
|
||
|
||
This means a NocoDB row references a `Message-ID` whose raw `.eml` was never uploaded to (or was deleted from) SeaweedFS. Two common causes:
|
||
|
||
1. **The `.eml` write failed** but the NocoDB upsert still succeeded (e.g. SeaweedFS was down briefly; the retry on the S3 PUT hit a 5-attempt limit; the pipeline then proceeded to NocoDB).
|
||
2. **The Message-ID is malformed** — e.g. a stray `<` or `>` made it into the NocoDB row but the file was written with the correct key.
|
||
|
||
**How to find the offending record in NocoDB:**
|
||
|
||
```bash
|
||
# For a finance row, replace m0thluc3jyz2iok with the module's table ID from §4.3
|
||
curl -G \
|
||
-H "xc-token: $NOCODB_API_TOKEN" \
|
||
--data-urlencode "where=(Message_ID,eq,<message-id-here>)" \
|
||
--data-urlencode "limit=1" \
|
||
"$NOCODB_BASE_URL/api/v2/tables/m0thluc3jyz2iok/records"
|
||
```
|
||
|
||
**How to dead-letter the row:**
|
||
|
||
1. Verify the `.eml` truly is missing in SeaweedFS (use the `s3_get_text_optional` helper in `pipelines/common.py` against `s3://$SEAWEEDFS_S3_BUCKET/eml/live/...`).
|
||
2. If it is missing, re-fetch the message from Gmail via IMAP using the `Message-ID` (Gmail's IMAP supports `UID FETCH` by `Message-ID` only via header search; the easier path is `since_uid`).
|
||
3. Re-dispatch `live-pipeline.yml` with `since_uid` set to the lowest affected UID. The pipeline will re-archive the `.eml` (overwriting the same key, or PUTing a new one) and re-UPSERT the NocoDB row.
|
||
4. If the IMAP mailbox no longer has the email (it was deleted from Gmail), the row is orphaned. Mark it manually in NocoDB (set a `Notes` field with a date) and move on — do not bulk-delete rows, the audit trail matters.
|
||
|
||
### 5.3 NocoDB 4xx on a UPSERT
|
||
|
||
Read the response body (NocoDB returns JSON like `{"msg":"..."}`). Common causes and recovery:
|
||
|
||
| Cause | Symptom | Recovery |
|
||
|----------------------------------------------------|--------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------|
|
||
| **Wrong table_id** | 404 from `/api/v2/tables/<id>/records` | Verify the table ID in `NOCODB_MODULE_TABLES` matches the live table ID (see §4.3). Re-bootstrap with `python scripts/apply_schemas.py` to print the current mapping. |
|
||
| **Missing required field** | 400 with `"<field> is required"` | Re-run `python scripts/apply_schemas.py` to add the column. UPSERT will retry on the next dispatch. |
|
||
| **Schema drift** (column renamed, type changed) | 400 with `"<field> validation failed"` | Edit `docs/schemas.md` first, then `scripts/apply_schemas.py`. Re-run the workflow. |
|
||
| **`Message_ID` UNIQUE violation on a POST** | This should not happen — `nocodb_upsert` does GET-then-PATCH/POST. If it does, race. | Re-run the workflow; the second pass will hit the GET and switch to PATCH. |
|
||
| **SingleSelect option not in the allowed list** | 400 with `"<option> is not a valid option for <field>"` | Add the option to the SingleSelect column in NocoDB UI, then re-run. |
|
||
|
||
The full retry policy lives in `pipelines/common.py::nocodb_upsert` (tenacity, 5 attempts, exponential backoff 1s → 30s). A 4xx surfaces immediately and stops the run; the next dispatch retries from the same checkpoint.
|
||
|
||
### 5.4 Ollama 4xx / timeout
|
||
|
||
1. **Verify the endpoint is reachable:**
|
||
```bash
|
||
curl -sS "$OLLAMA_BASE_URL/models" -H "Authorization: Bearer $OLLAMA_API_KEY"
|
||
```
|
||
Expect 200 with a JSON list. The 4xx response body is usually self-explanatory (model not found, auth required, OOM on the Ollama host).
|
||
2. **If the model is missing** (the response lists other models but not the one in `INBOXOS_LLM_MODEL`):
|
||
```bash
|
||
docker exec ollama ollama pull gemma4:e4b
|
||
```
|
||
3. **To switch the model** without redeploying: update the repo **variable** `INBOXOS_LLM_MODEL` (and `INBOXOS_LLM_MODEL_EXTRACT` for the larger extract model) in Gitea → repo → Settings → Actions → Variables. The next workflow run picks it up. Do not edit the prompt's `model:` frontmatter to "fix" a model outage — prompts are version-controlled, and that change goes through a PR.
|
||
4. **To raise the timeout:** set `INBOXOS_LLM_TIMEOUT` (in seconds, default 120). For very rich emails (86 KB Substack bodies) with the larger `INBOXOS_LLM_MODEL_EXTRACT` model, 180–240 s is not unusual.
|
||
5. **Check the Ollama host itself** for OOM:
|
||
```bash
|
||
docker stats ollama --no-stream
|
||
```
|
||
If memory is at the limit, the Ollama container is OOM-killing the loaded model. Either reduce the model size (`gemma4:e4b` instead of `gemma4:31b`) or add memory to the host.
|
||
|
||
### 5.5 Live pipeline falling behind
|
||
|
||
**Current symptom** (referenced in the INB-35 description): a single runner + 30+ minute cycles. The 5-minute cron cannot keep up because:
|
||
|
||
- The single runner is doing one job at a time (`cancel-in-progress: false`).
|
||
- IMAP UID backlog from a sync delay (e.g. Gmail was unreachable for an hour) means the next run has > 50 emails to process, and the runner is LLM-bound.
|
||
|
||
**Recovery options (in order of preference):**
|
||
|
||
1. **Add a second runner.** Already provisioned in `docker/docker-compose.yml` as `gitea-runner-2`; the only remaining step is the one-time registration token (see `docs/gitea-actions.md §2a` and [INB-33](/INB/issues/INB-33)). The historical workflow then runs in parallel on the second runner, freeing the first runner for the live cron.
|
||
2. **Bump the cron frequency.** Edit `.gitea/workflows/live-pipeline.yml` → `schedule` (e.g. `cron("*/2 * * * *")` for every 2 minutes). This is local-only, no board approval required. Note: 1 minute is the practical floor; Gitea will skip runs if the previous one is still in flight.
|
||
3. **Pause the cron** (board approval required). Use only when the pipeline is producing corrupted data or the LLM is rate-limiting. Two ways:
|
||
- In the Gitea UI: repo → Actions → `live-pipeline.yml` → **Disable workflow**.
|
||
- In code: open a PR that comments out the `schedule:` block; merge. Reverting the PR re-enables it.
|
||
4. **Bump `max_emails` per run** (default 50). The workflow input `max_emails` can be raised to 200, 500, etc. via `workflow_dispatch`. This is a one-shot — it does not change the default.
|
||
|
||
### 5.6 Historical pipeline run cancelled mid-way
|
||
|
||
**This is safe by construction.** The byte-offset checkpoint in `state/historical-state.json` is written every `BATCH_SIZE` messages (default 100) and on shutdown. A cancelled run:
|
||
|
||
1. Flushes whatever batch is currently in-flight to the checkpoint before exiting.
|
||
2. Leaves NocoDB and SeaweedFS in a consistent state — every successful `process_done` has already been committed.
|
||
|
||
**Recovery:** dispatch `historical-pipeline.yml` again with the **same** `mbox_object_key` and **no** `start_offset` input. The script will load `state/historical-state.json`, seek to the persisted byte offset, and continue.
|
||
|
||
**Do not** re-dispatch with `start_offset=0` unless you have intentionally deleted `state/historical-state.json`. Re-processing from offset 0 is safe (UPSERT dedupes) but wasteful — for a multi-GB `.mbox`, it will take hours and consume LLM tokens you do not need to spend.
|
||
|
||
To permanently restart a historical run from scratch (e.g. after a prompt overhaul you want to re-test):
|
||
|
||
```bash
|
||
# From a runner or any host with the S3 creds:
|
||
python -c "
|
||
import boto3, os
|
||
s3 = boto3.client('s3', endpoint_url=os.environ['SEAWEEDFS_S3_ENDPOINT'],
|
||
aws_access_key_id=os.environ['SEAWEEDFS_S3_ACCESS_KEY'],
|
||
aws_secret_access_key=os.environ['SEAWEEDFS_S3_SECRET_KEY'])
|
||
s3.delete_object(Bucket=os.environ['SEAWEEDFS_S3_BUCKET'], Key='state/historical-state.json')
|
||
print('state/historical-state.json deleted; next dispatch starts from offset 0')
|
||
"
|
||
```
|
||
|
||
Then dispatch `historical-pipeline.yml` with `start_offset=0` and the same `mbox_object_key`.
|
||
|
||
### 5.7 Backup and restore (REQ-INB-22)
|
||
|
||
The `scripts/backup.sh` and `scripts/restore.sh` helpers
|
||
snapshot and restore the three stateful services (NocoDB,
|
||
SeaweedFS, Gitea) to/from a timestamped directory under
|
||
`backups/<service>/<utc-timestamp>/`. The helpers are
|
||
idempotent: a re-run for the same service at the same UTC
|
||
timestamp is a no-op.
|
||
|
||
**Schedule.** A daily `cron` job on the operator host calls the
|
||
three backup helpers in sequence:
|
||
|
||
```cron
|
||
# /etc/cron.d/inboxos-backup — runs at 02:00 UTC daily.
|
||
0 2 * * * cto /opt/inboxos/scripts/backup.sh nocodb >> /var/log/inboxos-backup.log 2>&1
|
||
5 2 * * * cto /opt/inboxos/scripts/backup.sh seaweedfs >> /var/log/inboxos-backup.log 2>&1
|
||
10 2 * * * cto /opt/inboxos/scripts/backup.sh gitea >> /var/log/inboxos-backup.log 2>&1
|
||
```
|
||
|
||
**Retention.** 7 daily + 4 weekly + 0 monthly. The single-
|
||
operator posture is "the operator can change it" — the values
|
||
are the v1.1 ship default and are documented in
|
||
`scripts/backup.sh` header comment.
|
||
|
||
**Copy-paste invocation.**
|
||
|
||
```bash
|
||
# Backup one service.
|
||
scripts/backup.sh nocodb
|
||
scripts/backup.sh seaweedfs
|
||
scripts/backup.sh gitea
|
||
|
||
# Restore from a backup id (UTC timestamp).
|
||
scripts/restore.sh 2026-06-08T120000Z nocodb
|
||
scripts/restore.sh 2026-06-08T120000Z seaweedfs
|
||
scripts/restore.sh 2026-06-08T120000Z gitea
|
||
```
|
||
|
||
**Test-or-die gate.** A restore is not considered safe until
|
||
`pipelines/_tests/test_restore_offline.py` exits 0. The test
|
||
pins the exit-code table for missing args, missing env, missing
|
||
backup directory, and the SeaweedFS "no backup target configured"
|
||
path. It also exercises a git-bundle round-trip on an in-tree
|
||
repo. The full end-to-end (backup → wipe → restore → verify)
|
||
requires a live NocoDB / SeaweedFS / Gitea and is run by the
|
||
operator on the host.
|
||
|
||
---
|
||
|
||
## 6. Security and secrets
|
||
|
||
### 6.1 Secret inventory
|
||
|
||
All Gitea repo secrets live at **Gitea → repo → Settings → Actions → Secrets** (and a parallel **Variables** section for non-secret tunables). The 15 secrets consumed by the workflows, grouped by owner:
|
||
|
||
| Owner | Secret | Used by | Purpose |
|
||
|--------------|-------------------------------------|-----------|---------------------------------------------------------------|
|
||
| IMAP | `IMAP_HOST` | live | Gmail IMAP server (`imap.gmail.com`) |
|
||
| IMAP | `IMAP_PORT` | live | `993` |
|
||
| IMAP | `IMAP_USER` | live | Gmail address |
|
||
| IMAP | `IMAP_PASSWORD` | live | Gmail App Password (not the account password; 2FA required) |
|
||
| IMAP | `IMAP_USE_SSL` | live | `true` |
|
||
| IMAP | `IMAP_MAILBOX` | live | `INBOX` (default) |
|
||
| SeaweedFS | `SEAWEEDFS_S3_ENDPOINT` | both | `https://storage.cloudinit.dev` (or `http://seaweedfs-filer:8888` in-network) |
|
||
| SeaweedFS | `SEAWEEDFS_S3_ACCESS_KEY` | both | S3 access key |
|
||
| SeaweedFS | `SEAWEEDFS_S3_SECRET_KEY` | both | S3 secret key |
|
||
| SeaweedFS | `SEAWEEDFS_S3_BUCKET` | both | `inboxos` |
|
||
| NocoDB | `NOCODB_BASE_URL` | both | `https://noco.cloudinit.dev` |
|
||
| NocoDB | `NOCODB_API_TOKEN` | both | `xc-token` header value |
|
||
| NocoDB | `NOCODB_PROJECT_ID` | both | NocoDB project ID |
|
||
| NocoDB | `NOCODB_MODULE_TABLES` | both | JSON map of module name → NocoDB table ID |
|
||
| Ollama | `OLLAMA_BASE_URL` | both | `https://ollama-frp.cloudinit.dev/v1` |
|
||
| Ollama | `OLLAMA_API_KEY` | both | Bearer token if Ollama is auth-gated; empty string otherwise |
|
||
| Papra | `PAPRA_BASE_URL` | both | `https://papra.cloudinit.dev` (optional) |
|
||
| Papra | `PAPRA_API_TOKEN` | both | Bearer token (optional) |
|
||
| Papra | `PAPRA_ORGANIZATION_ID` | both | Papra org id (optional) |
|
||
| Gitea | `GITEA_INSTANCE_URL` | both | `http://gitea:3000` (in-network) |
|
||
| Gitea | `GITEA_USER` / `GITEA_PASS` | scripts | HTTP Basic Auth for the Gitea REST API (see `common.gitea_request`) |
|
||
|
||
That's 22 distinct secret values (the v1.0 ship counted 21 because the `GITEA_USER` / `GITEA_PASS` row was treated as one credential; v1.1 counts both values to match the row count in the table above). The issue description rounds to 15 because the Papra trio is optional; the table is exhaustive.
|
||
|
||
**Rotation schedule:** **None — all static.** This is an explicit, accepted trade-off for a single-operator, self-hosted system. The S3 access keys and NocoDB API token are long-lived. The Gmail App Password can be revoked from the Google account at any time without breaking anything else; the IMAP user must re-issue a new App Password and update the Gitea secret. The helper landed in v1.1 (REQ-INB-21); see §6.2 for the procedure.
|
||
|
||
### 6.2 Secret rotation (REQ-INB-21)
|
||
|
||
The `scripts/rotate_secrets.sh` helper rotates a Gitea repo secret in
|
||
place via `pipelines.common.gitea_request`. The new value is read
|
||
from **STDIN** (never a CLI argument) so it is not visible in
|
||
`ps` / process listings.
|
||
|
||
**Rotation ordering.** When multiple secrets need rotating, do them
|
||
in this order (each step invalidates fewer downstream services than
|
||
the next):
|
||
|
||
1. `NOCODB_API_TOKEN` first — it is the PII-bearing token (every
|
||
NocoDB write is gated on it; rotate during low-traffic). A
|
||
failed rotation here dead-letters new writes; rotate the
|
||
pre-shared token in NocoDB and update the Gitea secret.
|
||
2. `SEAWEEDFS_S3_ACCESS_KEY` and `SEAWEEDFS_S3_SECRET_KEY`
|
||
(treated as a pair) — the S3 archive and the pipeline state
|
||
files are gated on these. A failed rotation here stops both
|
||
pipelines; rotate the pre-shared key in SeaweedFS and update
|
||
the Gitea secrets.
|
||
3. `IMAP_PASSWORD` — the Gmail App Password. A failed rotation
|
||
here stops the live pipeline only; the historical pipeline is
|
||
unaffected. Update the Gmail App Password and the Gitea secret.
|
||
4. `GITEA_USER` / `GITEA_PASS` last — the Basic Auth credentials
|
||
that the helper itself uses. Rotating these invalidates the
|
||
helper's own running session; do them after the helper has
|
||
returned successfully for the other three.
|
||
|
||
**Test-or-die gate.** A rotation is not considered safe until
|
||
`pipelines/_tests/test_rotate_secrets_offline.py` exits 0. The
|
||
test exercises the dry-run path, the apply branch shape, and the
|
||
env-validation failure mode. The dry-run path is the one operators
|
||
should run first; it prints a redacted diff of the new value
|
||
(`sha256:8hex`) without making the PUT.
|
||
|
||
**Copy-paste invocation.**
|
||
|
||
```bash
|
||
# Dry-run first; no PUT issued.
|
||
echo "$NEW_NOCODB_API_TOKEN" | scripts/rotate_secrets.sh NOCODB_API_TOKEN
|
||
|
||
# Real rotation; PUTs the new value, then verifies via GET.
|
||
echo "$NEW_NOCODB_API_TOKEN" | scripts/rotate_secrets.sh --apply NOCODB_API_TOKEN
|
||
```
|
||
|
||
The helper exits 0 on success, 2 on missing env / missing value,
|
||
and 3 on Gitea API failure. See
|
||
`pipelines/_tests/test_rotate_secrets_offline.py` for the test
|
||
contract and the helper's exit-code table in its header comment.
|
||
|
||
### 6.3 Why we never commit secrets
|
||
|
||
- `.gitignore` excludes `.env` and `.env.*` except `.env.example`. The example file contains placeholder values only.
|
||
- The CTO host deliberately does **not** have write-capable S3 credentials (only the read-only S3 keys used for verification). A leaked CTO host key cannot corrupt the `.eml` archive.
|
||
- The Gitea service account used for the REST API (`GITEA_USER=cto` in `.env.example`) is scoped to the `jc-capital/inboxos` repo. It can read/write workflow secrets, but it cannot create new repos or admin the Gitea instance.
|
||
- All secret reads happen via `${{ secrets.X }}` in the workflow YAMLs; the values are injected as environment variables into the ephemeral runner container and never appear in the build log.
|
||
|
||
### 6.4 Data-sovereignty posture
|
||
|
||
- **No external API calls for PII-bearing data.** The only outbound network calls from the runner are to the in-network services (`http://gitea:3000`, `http://nocodb:8080`, `http://seaweedfs-filer:8888`, `http://ollama:11434`, `http://papra:1221`). The Ollama URL is FRP-tunneled to the local Ollama container — the FRP tunnel terminates inside the operator's network; no third-party LLM provider sees the request body.
|
||
- **Local LLM inference.** Default model is `gemma4:e4b` (4 B effective parameters). All `inboxos:classify` and `inboxos:extract-*` calls hit the local Ollama. There is no fallback to a cloud LLM. If Ollama is down, the pipeline dead-letters and the next run retries.
|
||
- **No telemetry from any pipeline.** `pipelines/common.py` does not call home. Gitea Actions emits standard runner logs, which are stored on the Gitea host (also self-hosted).
|
||
|
||
---
|
||
|
||
## 7. Cost and capacity
|
||
|
||
### 7.1 Per-message cost
|
||
|
||
**~$0 / message.** All inference is local (Ollama on the operator's host). The only recurring costs are:
|
||
|
||
| Cost | Magnitude (single operator) |
|
||
|-----------------------------------------------|----------------------------------------------------------------|
|
||
| Compute (Ollama host + Gitea host + runners) | One bare-metal or small VM; existing infrastructure. |
|
||
| Bandwidth (Gmail IMAP, FRP tunnel to Ollama) | Negligible at < 100 emails / day. |
|
||
| Storage (SeaweedFS volume) | See §7.2. |
|
||
| LLM API costs | **$0** — local inference. |
|
||
|
||
### 7.2 Storage growth
|
||
|
||
- A typical personal email `.eml` is ~30–100 KB. 100 KB / email is the safe upper bound used in capacity planning.
|
||
- **10 000 emails ≈ 1 GB** of raw `.eml` in SeaweedFS.
|
||
- The SeaweedFS volume is provisioned at `seaweedfs-data` (named Docker volume); size is bounded by the host's disk. No quota is enforced at the SeaweedFS level.
|
||
- The NocoDB side is tiny: even 10 000 rows across four tables is single-digit MB.
|
||
|
||
### 7.3 Compute
|
||
|
||
- **One Gitea runner** (now two, post-[INB-33](/INB/issues/INB-33)), one Ollama container with `gemma4:e4b` loaded.
|
||
- **Throughput observed (live):** ~22 messages / 36 min on a single-runner + IMAP-backlog-saturated cycle. This is LLM-bound; each `inboxos:classify` + 1–2 `inboxos:extract-*` calls adds up to ~90 s per email with `gemma4:e4b` on CPU. A GPU host would cut this roughly 5–10× but is not required at the current scale.
|
||
- **Throughput observed (historical):** ~5–10 min per 10 K messages on `gemma4:e4b` CPU, dominated by the LLM calls. Switching to a smaller model (e.g. `gemma3:4b`) would roughly halve this; switching to a larger model (`gemma4:12b`) would roughly double it. Prompt quality and JSON-contract compliance are the binding constraints, not raw tokens/second.
|
||
|
||
---
|
||
|
||
## 8. Glossary and appendix
|
||
|
||
### 8.1 Glossary
|
||
|
||
- **Message-ID** — The RFC 5322 `Message-ID:` header, with angle brackets stripped. The natural primary key for every module table in NocoDB and the natural key for the `.eml` S3 key.
|
||
- **UPSERT** — "Update or insert." `pipelines.common.nocodb_upsert` does this by doing a GET by `Message_ID` and then a PATCH (if found) or POST (if not). There is no plain `INSERT` path in the codebase. The CSV / SQL concept maps 1:1 to the GET-then-PATCH/POST pattern.
|
||
- **mbox** — A plain-text file format for archiving email. Each message begins with `From <mailbox> <date>` and ends with a blank line. The historical pipeline uses `mbox_stream.py` to read it as a byte stream, never materialising the full file in memory.
|
||
- **IMAP** — Internet Message Access Protocol. The live pipeline uses `IMAPClient` against Gmail's `imap.gmail.com:993` (SSL). Polling, not push, because Gmail's push (IDLE) is unreliable.
|
||
- **FRP tunnel** — Fast Reverse Proxy. The Ollama service is exposed at `https://ollama-frp.cloudinit.dev/v1` via FRP, terminating inside the operator's network. Lets the runner talk to Ollama over HTTPS without opening inbound ports.
|
||
- **Checkpoint state** — The persisted byte offset (`historical-state.json`) or IMAP UID (`live-state.json`) that lets a pipeline resume from where it left off after a crash. The state file is the only persistent state between workflow runs.
|
||
- **Dead-letter** — A poison message or one that could not be processed is moved to `s3://$SEAWEEDFS_S3_BUCKET/eml/dead-letter/...` with a marker file describing the reason. The cursor advances so the rest of the email stream is not blocked. Dead-lettered items require manual review.
|
||
- **Idempotency** — "Running the same operation twice has the same effect as running it once." Achieved in InboxOS by (a) Message-ID UPSERT in NocoDB, (b) deterministic S3 keys, and (c) checkpoint-based resume. A redelivered Gmail message, a re-dispatched workflow, and a replayed `.mbox` are all safe.
|
||
|
||
### 8.2 Cross-references
|
||
|
||
- **Schemas:** `docs/schemas.md` — NocoDB table schemas (column-by-column).
|
||
- **Extraction contract:** `docs/extraction-contract.md` — how a raw email becomes a structured NocoDB record, the prompt family, and failure semantics.
|
||
- **Gitea Actions:** `docs/gitea-actions.md` — runner provisioning, second-runner setup, secrets, the Basic Auth model, observability, rollback.
|
||
- **Pipelines:** `pipelines/README.md` — pipeline flow diagrams, idempotency model, rollback procedure.
|
||
- **Papra write policy:** `docs/papra-write-policy.md` — when the pipeline writes to Papra (attachments, body for crm/finance/legal modules), the dry-run default, the live-write gate (REQ-INB-20), and the `papra_index` audit table (REQ-INB-19).
|
||
- **Phase 1.1 verification:** `docs/phase-1.1-verification/inb-28-report.md` — the schema-bootstrap deliverable that locked in the four table IDs and column counts cited in §4.3.
|
||
- **Secret rotation:** `docs/operations-runbook.md §6.2` (new in v1.1, renumbered in v1.3 from §6.4) — the rotation helper, the ordering, and the test-or-die gate (REQ-INB-21).
|
||
- **Metrics surface:** `docs/operations-runbook.md §4.5` (new in v1.1) — the per-run `metrics.jsonl` shape and the tail-and-summarize procedure (REQ-INB-23).
|
||
- **Backup and restore:** `docs/operations-runbook.md §5.7 + §10` (new in v1.1) — the `backup.sh` / `restore.sh` helpers, the daily cron, and the RTO < 4h / RPO < 24h targets (REQ-INB-22).
|
||
|
||
### 8.3 Quick-reference: log-event search strings
|
||
|
||
If a post-mortem needs to find every place a particular event is emitted:
|
||
|
||
```bash
|
||
grep -rn 'log_event(logger, "process_done"' pipelines/
|
||
grep -rn 'log_event(logger, "classified"' pipelines/
|
||
grep -rn 'log_event(logger, "module_table_missing"' pipelines/
|
||
grep -rn 'log_event(logger, "dead_letter_unclassified"' pipelines/
|
||
grep -rn 'log_event(logger, "extract_permanent_failure"' pipelines/
|
||
```
|
||
|
||
For the full event vocabulary, `grep -n "log_event" pipelines/common.py pipelines/live_pipeline.py pipelines/historical_pipeline.py`.
|
||
|
||
### 8.4 Quick-reference: env vars (in alphabetical order)
|
||
|
||
| Env var | Default | Purpose |
|
||
|----------------------------------|------------------------------------------|-------------------------------------------------------------------------|
|
||
| `GITEA_INSTANCE_URL` | — (required) | Gitea REST API base. `http://gitea:3000` in-network. |
|
||
| `GITEA_USER` | — (required for Gitea REST calls) | HTTP Basic Auth user. |
|
||
| `GITEA_PASS` | — (required for Gitea REST calls) | HTTP Basic Auth password. |
|
||
| `IMAP_HOST` / `IMAP_PORT` | — / `993` | Gmail IMAP endpoint. |
|
||
| `IMAP_USER` / `IMAP_PASSWORD` | — | Gmail App Password credentials. |
|
||
| `IMAP_USE_SSL` / `IMAP_MAILBOX` | `true` / `INBOX` | IMAP connection toggles. |
|
||
| `INBOXOS_LLM_BACKEND` | `ollama` | `ollama` (recommended) or `opencode` (legacy, broken on 1.16.2). |
|
||
| `INBOXOS_LLM_MODEL` | `gemma4:e4b` | Default model for classify and any non-extract command. |
|
||
| `INBOXOS_LLM_MODEL_EXTRACT` | `gemma4:e4b` | Model for `inboxos:extract-*` commands. |
|
||
| `INBOXOS_LLM_MAX_TOKENS` | `4096` | Completion budget. 1024 was too tight for the 11-field extract schema. |
|
||
| `INBOXOS_LLM_TIMEOUT` | `120` | Seconds per LLM call. |
|
||
| `NOCODB_API_TOKEN` | — | `xc-token` header value. |
|
||
| `NOCODB_BASE_URL` | — | `https://noco.cloudinit.dev` |
|
||
| `NOCODB_MODULE_TABLES` | — | JSON map `{"finance":"tbl_…","crm":"tbl_…",…}`. |
|
||
| `NOCODB_PROJECT_ID` | — | NocoDB project ID. |
|
||
| `OLLAMA_API_KEY` | unset | Bearer token if Ollama is auth-gated. |
|
||
| `OLLAMA_BASE_URL` | `https://ollama-frp.cloudinit.dev/v1` | OpenAI-compatible chat completions endpoint. |
|
||
| `OPENCODE_BIN` | `opencode` | CLI binary used by `opencode_run_command` (legacy path only). |
|
||
| `OPENCODE_PROMPT_DIR` | `./.opencode/commands` | Where the `.opencode/commands/inboxos:*.md` prompts live. |
|
||
| `PAPRA_API_TOKEN` | unset | Bearer token for Papra uploads. |
|
||
| `PAPRA_BASE_URL` | unset | Papra API base. |
|
||
| `PAPRA_BODY_WRITE_MODULES` | `crm,finance,legal` | Comma-separated modules whose body (not attachments) gets uploaded. |
|
||
| `PAPRA_DRY_RUN` | `true` | `false` to enable real Papra writes (CEO-approved only). |
|
||
| `PAPRA_METADATA_ONLY_MODULES` | `intel,promotions` | Modules whose email is *not* written to Papra (metadata only). |
|
||
| `PAPRA_ORGANIZATION_ID` | unset | Papra org id. |
|
||
| `SEAWEEDFS_S3_ACCESS_KEY` | — | S3 access key. |
|
||
| `SEAWEEDFS_S3_BUCKET` | — | `inboxos` (default). |
|
||
| `SEAWEEDFS_S3_ENDPOINT` | — | `https://storage.cloudinit.dev` (or in-network `http://seaweedfs-filer:8888`). |
|
||
| `SEAWEEDFS_S3_SECRET_KEY` | — | S3 secret key. |
|
||
|
||
---
|
||
|
||
## 9. Phase 5 acceptance gate
|
||
|
||
> This appendix is the **must-have checklist** that closes
|
||
> InboxOS Phase 5 (Full Deployment — INB-7). It is the integrator's
|
||
> reference: every item must be checkbox-green before the
|
||
> `.ciagent/inboxos/ROADMAP.md` Phase 5 status row is flipped to
|
||
> "executed". The 20 must-haves below were produced by the
|
||
> CIAgent plan at `.ciagent/inboxos/plans/05-PLAN.md` and verified
|
||
> during the Wave 1 + Wave 2 + Wave 3 execution pass (see git log
|
||
> on `milestone/v1.0-initial`).
|
||
|
||
### 9.1 Requirements coverage
|
||
|
||
| # | Must-have (verifiable) | Source-of-truth location | REQ-INB | Owner |
|
||
|----|------------------------|--------------------------|:-------:|-------|
|
||
| 1 | `pipelines/live_pipeline.py` runs on a 5-minute cron and on `workflow_dispatch` | `.gitea/workflows/live-pipeline.yml:62-65` | REQ-INB-1 | devops-engineer |
|
||
| 2 | `pipelines/historical_pipeline.py` is `workflow_dispatch` only and streams a local `.mbox` end-to-end | `.gitea/workflows/historical-pipeline.yml:54-58` | REQ-INB-2 | devops-engineer |
|
||
| 3 | All NocoDB writes are GET-then-PATCH/POST on `Message_ID`; no plain INSERT path | `pipelines/common.py:196-255` | REQ-INB-3 | data-engineer |
|
||
| 4 | `inboxos:classify` + `inboxos:extract-<module>` is the only extraction path | `pipelines/common.py:632-653`, `docs/extraction-contract.md` | REQ-INB-4 | ml-engineer |
|
||
| 5 | Four module tables (`finance`, `crm`, `intel`, `promotions`) with the column set per `docs/schemas.md` | `docs/schemas.md`, `docs/phase-1.1-verification/inb-28-report.md` | REQ-INB-5 | data-engineer |
|
||
| 6 | `docs/operations-runbook.md` is the canonical day-1 incident reference | this file | REQ-INB-6 | lead-developer |
|
||
| 7 | Two self-hosted Gitea Actions runners are provisioned and online | `docker/docker-compose.yml:103-148`, `docs/gitea-actions.md §2` | REQ-INB-7 | devops-engineer |
|
||
| 8 | `ci-pr.yml` runs fast unit tests + 100MB e2e + live Ollama smoke on every PR | `.gitea/workflows/ci-pr.yml:63-230` | REQ-INB-8 | devops-engineer |
|
||
| 9 | Papra integration is dry-run by default; the first ship does not call the real Papra API | `pipelines/common.py:729`, `docs/papra-write-policy.md` | REQ-INB-9 | security-engineer |
|
||
| 10 | No third-party SaaS touches PII; only the IMAP connection to Gmail leaves the self-hosted perimeter | `docker/docker-compose.yml`, this runbook §6 | REQ-INB-10 | security-engineer |
|
||
| 11 | A single pinned Ollama model (`gemma4:e4b`) is the production extraction path; no model-escalation chain | `pipelines/common.py:467-468`, `.opencode/commands/inboxos:*.md` frontmatter | REQ-INB-11 | ml-engineer |
|
||
| 12 | `actions/upload-artifact` is pinned to v3 in the workflows that emit per-run logs (live + historical); `ci-pr.yml` has no `upload-artifact` step and inherits GHES compat from the runner image, not from the workflow pin | `.gitea/workflows/live-pipeline.yml:150` (`uses: actions/upload-artifact@v3`), `.gitea/workflows/historical-pipeline.yml:170` (same). `ci-pr.yml` has no `upload-artifact` step; the runbook's previous glob was misleading. | REQ-INB-12 | devops-engineer |
|
||
| 13 | A synthetic UPSERT test exists and exercises the NocoDB idempotency path end-to-end | `pipelines/_tests/test_upsert_synthetic.py` | REQ-INB-13 | data-engineer |
|
||
| 14 | All extraction prompts are version-controlled in `.opencode/commands/` and invoked by name | `.opencode/commands/inboxos:*.md` (5 files), `pipelines/common.py::llm_run_command` | REQ-INB-14 | ml-engineer |
|
||
|
||
### 9.2 Reconciliation drift closure
|
||
|
||
The seven doc/code drifts surfaced in research (P-1 through P-7)
|
||
were closed during the Wave 1 + Wave 2 execution pass:
|
||
|
||
| # | Drift | Closed by | PR | REQ-INB |
|
||
|----|-------|-----------|----|:-------:|
|
||
| 15 | Model pin drift (`lfm2.5:8b` → `gemma4:e4b` in docs) | `docs: align pipelines/README + extraction-contract to gemma4:e4b pin` | #A | REQ-INB-11 |
|
||
| 16 | `OLLAMA_BASE_URL` `/v1` suffix canonicalised | `docs: align pipelines/README + extraction-contract to gemma4:e4b pin` (folded into #A) | #A | REQ-INB-11, REQ-INB-10 |
|
||
| 17 | Papra `INTAKE_EMAILS_IS_ENABLED` flipped to `false` in compose | `docker: disable Papra catch-all intake; align with cancelled policy` | #E | REQ-INB-9 |
|
||
| 18 | State-file field names in runbook §4.4 corrected | `docs(runbook §4.4): correct state-file field names` | #H | REQ-INB-3, REQ-INB-6 |
|
||
| 19 | Live-vs-historical `Papra_Link` asymmetry documented | `docs: document live-vs-historical Papra_Link asymmetry` + `docs(extraction-contract)` — owner: backend-engineer (the P-7 `papra_decide` investigation; the asymmetry is structural, the doc is the deliverable) | #C, #B | REQ-INB-4, REQ-INB-9 |
|
||
| 20 | Extraction-contract rewritten to describe Ollama as production LLM caller; opencode marked legacy | `docs(extraction-contract): describe Ollama as production LLM caller; opencode marked legacy` | #B | REQ-INB-4 |
|
||
|
||
> The `papra_decide` behavioural asymmetry in `live_pipeline.py`
|
||
> (P-7 in the research brief) was investigated and found to be a
|
||
> no-op: the live pipeline already calls `papra_decide` exactly
|
||
> once with the post-classify `modules` list
|
||
> (`pipelines/live_pipeline.py:490`). The historical pipeline
|
||
> calls it twice (pre-classify attachment walk + post-classify
|
||
> body write), but the live pipeline has no pre-classify walk.
|
||
> Decision recorded as `ED-1` in `.ciagent/inboxos/CI-LOG.md`.
|
||
|
||
### 9.3 Closing the gate
|
||
|
||
A Phase 5 close is the date on which all 20 must-haves are
|
||
checkbox-green and the `.ciagent/inboxos/ROADMAP.md` Phase 5
|
||
status row is flipped to "executed". The lead-developer is the
|
||
integrator; every active persona (data-engineer, backend-engineer,
|
||
devops-engineer, ml-engineer, security-engineer, lead-developer)
|
||
gets a review pass on this section.
|
||
|
||
### 9.4 v1.1 acceptance gate
|
||
|
||
> This appendix is the **must-have checklist** that closes
|
||
> InboxOS v1.1 Phase 1. It is the integrator's reference: every
|
||
> item must be checkbox-green before the v1.1 ship merge. The
|
||
> 11 must-haves below were produced by the CIAgent plan at
|
||
> `.ciagent/inboxos/plans/01-PLAN.md` and verified during the
|
||
> Wave 1 + Wave 2 + Wave 3 execution pass (see git log on
|
||
> `phase/01-v1.1-execute`).
|
||
>
|
||
> The 11 candidates are `[CANDIDATE]` until the CPO authors the
|
||
> matching entries in `docs/requirements.md`; the must-haves
|
||
> below reference the candidate IDs as a planning aid. The
|
||
> `.ciagent/inboxos/ROADMAP.md` Phase 1 status row is not
|
||
> flipped by the lead-developer; the flip is the CPO's call.
|
||
> The lead-developer's gate is "every must-have is checkbox-
|
||
> green and the 11 PRs are merged".
|
||
|
||
| # | Must-have (verifiable) | Source-of-truth location | Owner | REQ-INB |
|
||
|----|------------------------|--------------------------|-------|:-------:|
|
||
| 1 | `.env.example` lines 96-99 reference `gemma4:e4b` only (no `lfm2.5:8b` or `gemma4:12b` comments) | `.env.example:96-99` | devops-engineer | REQ-INB-15 |
|
||
| 2 | Runbook §9.1 #12 cites `upload-artifact@v3` only in the workflows that emit per-run logs (live + historical); `ci-pr.yml` excluded | `docs/operations-runbook.md:544` (the row updated in T1.2) | devops-engineer | REQ-INB-16 |
|
||
| 3 | Runbook §6.1 says "22 distinct secret values" (matching the 22 data rows in the table) | `docs/operations-runbook.md:399` (the sentence updated in T1.3) | devops-engineer | REQ-INB-17 |
|
||
| 4 | `inb-28-report.md` "Per-table row counts at freeze" is filled with one row per module table | `docs/phase-1.1-verification/inb-28-report.md:67-77` | data-engineer | REQ-INB-18 |
|
||
| 5 | `papra_index` NocoDB table exists with the 7 documented columns; `docs/schemas.md` and `scripts/apply_schemas.py` reflect the table; the v1.0 schema freeze is preserved for the 4 module tables | `docs/schemas.md` (new `papra_index` section, T1.4a), `scripts/apply_schemas.py` (new `AUDIT_TABLES` entry, T1.4b), `docs/phase-1.1-verification/inb-28-report.md` (the freeze amendment, T1.4a) | data-engineer | REQ-INB-19 |
|
||
| 6 | `PAPRA_DRY_RUN_OVERRIDE` env var exists; the new `papra_write_gate` event is emitted by both pipelines (via the T1.5 helper + T2.2 call-sites); the canary procedure is in `docs/papra-write-policy.md`; new test cases exercise the override and the helper | `pipelines/common.py` `PapraConfig` + `emit_papra_write_gate_event` (T1.5), `pipelines/live_pipeline.py` + `pipelines/historical_pipeline.py` (T2.2 call-sites), `pipelines/_tests/test_papra_decide_offline.py` (T1.5 + T2.2 cases), `docs/papra-write-policy.md` "Live-write gate" (T1.5) | security-engineer | REQ-INB-20 |
|
||
| 7 | `scripts/rotate_secrets.sh` exists with `--dry-run` and real-run modes; `pipelines/_tests/test_rotate_secrets_offline.py` passes; `docs/operations-runbook.md §6.2` documents the rotation ordering | `scripts/rotate_secrets.sh` (new), `pipelines/_tests/test_rotate_secrets_offline.py` (new, 3/3 tests pass), `docs/operations-runbook.md §6.2` (new) | security-engineer | REQ-INB-21 |
|
||
| 8 | `scripts/backup.sh` and `scripts/restore.sh` exist; `pipelines/_tests/test_restore_offline.py` round-trips a known fixture; `docs/operations-runbook.md §5.7 + §10` state RTO < 4h and RPO < 24h | `scripts/backup.sh` (new), `scripts/restore.sh` (new), `pipelines/_tests/test_restore_offline.py` (new, 7/7 tests pass), `docs/operations-runbook.md §5.7 + §10` (new) | devops-engineer | REQ-INB-22 |
|
||
| 9 | `pipelines/_metrics/emit.py` writes one JSONL line per run with the documented counter and gauge vocabulary; `docs/operations-runbook.md §4.5` documents the tail-and-summarize procedure; a new test exercises the emitter | `pipelines/_metrics/emit.py` (new), `pipelines/live_pipeline.py` + `pipelines/historical_pipeline.py` (T2.2 call-sites), `pipelines/_tests/test_metrics_offline.py` (new, 2/2 tests pass), `docs/operations-runbook.md §4.5` (new) | data-engineer | REQ-INB-23 |
|
||
| 10 | `plans/05-PLAN.md` parallelization table has the explicit three-file split footnote (P1-1 closure); runbook §9.2 row 19 has the backend-engineer persona annotation (P2-4 closure); CI-LOG P1-1 + P2-4 entries are marked closed | `.ciagent/inboxos/plans/05-PLAN.md:318-330` (footnote), `docs/operations-runbook.md:674` (row 19 owner annotation), `.ciagent/inboxos/CI-LOG.md:96-97,112-113,115` (closed_in pointers) | lead-developer | REQ-INB-24 |
|
||
| 11 | The live pipeline's `process_one_message` patches `Papra_Link` onto every NocoDB row after a successful `papra_write_attachment`; the "Live pipeline note" paragraph in `docs/papra-write-policy.md §Rule 1` is removed; the asymmetry paragraph in `docs/extraction-contract.md §Live vs historical: Papra_Link asymmetry` is removed; `ARCHITECTURE.md §Document Store` is updated to reflect parity | `pipelines/live_pipeline.py:486-555` (the new PATCH loop + `papra_link_patched` event, T1.7), `docs/papra-write-policy.md:55-72` (the §Rule 1 asymmetry note removed by T2.3), `docs/extraction-contract.md:155-181` (the subsection removed by T1.7), `.ciagent/inboxos/ARCHITECTURE.md:65-72` (the paragraph replaced by T1.7) | backend-engineer | REQ-INB-25 |
|
||
|
||
A v1.1 close is the date on which all 11 must-haves are
|
||
checkbox-green and the CPO has promoted the 11 candidates to
|
||
real requirements in `docs/requirements.md`. The
|
||
`.ciagent/inboxos/ROADMAP.md` Phase 1 status row is not flipped
|
||
to "executed" by the lead-developer; the flip is the CPO's
|
||
call. The lead-developer's gate is "every must-have is
|
||
checkbox-green and the 11 PRs are merged".
|
||
|
||
---
|
||
|
||
## 10. Disaster recovery targets (REQ-INB-22)
|
||
|
||
The backup / restore procedure in §5.7 is the substrate; this
|
||
section is the **commitment**.
|
||
|
||
- **RTO (Recovery Time Objective):** **< 4 hours**, single-
|
||
operator restore. The breakdown:
|
||
- 30 min — re-read §5.7 + §6.2 and decide which services
|
||
to restore (typically: all three, in order: Gitea first
|
||
so the workflows and secrets are back, then NocoDB so
|
||
the module-table IDs are available, then SeaweedFS).
|
||
- 60 min — restore the Gitea bundle and force-push the
|
||
`jc-capital/inboxos` repo. Verify the workflows are
|
||
visible at the Gitea UI.
|
||
- 60 min — restore the NocoDB export. The export is a
|
||
JSON dump; the operator opens the NocoDB UI and uses
|
||
Settings → Import to load the JSON. Verify a known row
|
||
via the `curl ... where=(Message_ID,eq,...)` pattern
|
||
in §5.2.
|
||
- 60 min — restore the SeaweedFS mirror. The `aws s3 sync`
|
||
(or boto3 fallback) copies every object from the
|
||
backup bucket to the primary bucket. Verify a known
|
||
`.eml` via the `s3_get_text_optional` helper.
|
||
- 30 min — buffer for unexpected drift (single-operator
|
||
runs are not 24/7; if the restore starts at 02:00 UTC
|
||
the operator is fresh by 04:00 UTC).
|
||
|
||
- **RPO (Recovery Point Objective):** **< 24 hours** data
|
||
loss. The daily backup cron (§5.7) runs at 02:00 UTC, so
|
||
the worst-case loss is the previous 24 hours of incoming
|
||
email. In a typical single-operator inbox this is < 50
|
||
messages, all recoverable from Gmail via `since_uid` (the
|
||
live pipeline's recovery path in §5.2).
|
||
|
||
The RTO and RPO values are explicit and CPO-acknowledged.
|
||
The ideation report Tier 2 T2-C marked them as "guesses"
|
||
pending CPO sign-off; the values recorded here are the
|
||
v1.1 ship defaults. Changes to the values require a new
|
||
CPO-approved REQ-INB (the values are not engineer-editable
|
||
in isolation).
|
||
|
||
---
|
||
|
||
*End of runbook. Next review: after the v1.3 ship (2026-06-08, milestone `milestone/v1.3-initial`), or when the next failure mode is added to §5. Last verified against the source files cited inline; any divergence is a bug in this document — fix the doc, do not just edit around it.*
|