---ci--- project: inboxos phase: 1 milestone: v1.4 status: execute wave: 3 task: T2 ---/ci---
70 KiB
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 — issued under 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-1andgitea-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(ins3://$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 — 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):
- Load the IMAP UID checkpoint from
s3://$SEAWEEDFS_S3_BUCKET/state/live-state.json. - Poll IMAP for
UID > last_uid(bounded byMAX_EMAILS_OVERRIDE, default 50). - For each message, in UID-ascending order:
- Back up the raw
.emltos3://$SEAWEEDFS_S3_BUCKET/eml/live/<yyyy>/<mm>/<dd>/<Message-ID>.eml(skip if exists). - Call
inboxos:classifyto 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.
- Back up the raw
What success looks like in the run log:
- One
live_run_startevent at the top. - One or more
process_start→eml_backed_up→classified→process_doneevent groups, in UID-ascending order. - Every
process_doneis followed by a checkpoint write (visible as thestate/live-state.jsonPUT in S3, or as the next call toimap_fetched).
What failure looks like:
- A
classify_errororextract_permanent_failureevent means the pipeline stops at that UID. The next run retries it. - A
dead_letter_unclassifiedevent means the email had no module match — the raw.emlis preserved ateml/dead-letter/<mid>.emland the cursor advances. - A
missing_message_idevent means the email lacked aMessage-ID:header. The raw.emlis preserved ateml/dead-letter/uid-<n>.eml. - A non-zero job exit code with no
live_run_donein 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 byMessage_IDand PATCHes if present, POSTs otherwise. Re-running the same.mboxproduces zero duplicates. - Byte-offset checkpoint is persisted to
s3://$SEAWEEDFS_S3_BUCKET/state/historical-state.jsoneveryBATCH_SIZEmessages (default 100) and on shutdown. A crashed run resumes from the last checkpoint on the next dispatch. - Object keys are deterministic by
Message-IDand date, so re-uploading the same.emloverwrites 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:
- Re-dispatch
historical-pipeline.ymlwith the samembox_object_keyand nostart_offsetinput. The script loadsstate/historical-state.jsonand resumes from the persisted byte offset. - Do not dispatch with
start_offset=0unless 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 itsMessage-IDin the run log, then write a marker object toeml/dead-letter/poison/<mid>.jsoncontaining 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-testsexercisesgitea_request,mbox_stream, the offline parts oflive_pipeline, and the fast subset ofhistorical_pipeline(config, parsing, dead-letter paths).e2e-100mbbuilds a 100 MB synthetic.mboxand 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-smokecalls 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 whenOLLAMA_BASE_URLis not set; surfaces a clearskippednotice rather than failing the PR.
4. Observability
4.1 Reading a workflow run log
- Open the Gitea UI → repo → Actions → click the run.
- The job's
Run process-new-emails/Run process-mboxstep shows stdout in the Gitea Actions UI. Each line is a JSON object emitted bylog_event. - Expand the Upload run log as artifact step. The per-run log file is downloaded as
live-pipeline-logorhistorical-pipeline-log. This is identical to what was streamed to stdout, and is the canonical artifact to attach to a post-mortem. - 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_recoveredfield 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:
cat pipelines/_artifacts/live-metrics-*.jsonl \
| jq -s '[.[].counters.messages_processed] | add'
To inspect the last 20 metrics lines in time order:
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:
- Confirm it is stuck. Open Gitea Actions → run → check whether the
Run process-new-emailsstep has emitted any new log lines in the last 10 minutes. A runner that is downloading a large.mboxis making progress (watch for S3 GET bytes) — a runner that is silent is OOM'd or wedged. - Check the host's container health.
Memory at 100% with no progress for > 10 minutes = OOM. CPU at 0% with no progress = wedged.
docker ps --filter "name=gitea-runner" --format "table {{.Names}}\t{{.Status}}\t{{.CPUPerc}}\t{{.MemUsage}}" - Drain the stuck job. From Gitea UI → run → Cancel job. The live workflow's
cancel-in-progress: falsewill 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. - Restart the runner.
If
docker compose -f docker/docker-compose.yml restart gitea-runnergitea-runner-2is also stuck, restart it too. The other runner will pick up new jobs within ~30 s. - Verify recovery.
Expect 2 entries with
curl -u "$GITEA_USER:$GITEA_PASS" \ https://git.cloudinit.dev/api/v1/repos/jc-capital/inboxos/actions/runnersstatus: "online". Then dispatchlive-pipeline.ymlmanually with a smallmax_emails(e.g.5) to confirm the end-to-end loop. - If recurring: the runner is under-provisioned. Increase its memory limit in
docker/docker-compose.yml(mem_limit: 4gor 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:
- The
.emlwrite 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). - 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:
# 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:
- Verify the
.emltruly is missing in SeaweedFS (use thes3_get_text_optionalhelper inpipelines/common.pyagainsts3://$SEAWEEDFS_S3_BUCKET/eml/live/...). - If it is missing, re-fetch the message from Gmail via IMAP using the
Message-ID(Gmail's IMAP supportsUID FETCHbyMessage-IDonly via header search; the easier path issince_uid). - Re-dispatch
live-pipeline.ymlwithsince_uidset 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. - 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
Notesfield 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
- Verify the endpoint is reachable:
Expect 200 with a JSON list. The 4xx response body is usually self-explanatory (model not found, auth required, OOM on the Ollama host).
curl -sS "$OLLAMA_BASE_URL/models" -H "Authorization: Bearer $OLLAMA_API_KEY" - If the model is missing (the response lists other models but not the one in
INBOXOS_LLM_MODEL):docker exec ollama ollama pull gemma4:e4b - To switch the model without redeploying: update the repo variable
INBOXOS_LLM_MODEL(andINBOXOS_LLM_MODEL_EXTRACTfor the larger extract model) in Gitea → repo → Settings → Actions → Variables. The next workflow run picks it up. Do not edit the prompt'smodel:frontmatter to "fix" a model outage — prompts are version-controlled, and that change goes through a PR. - To raise the timeout: set
INBOXOS_LLM_TIMEOUT(in seconds, default 120). For very rich emails (86 KB Substack bodies) with the largerINBOXOS_LLM_MODEL_EXTRACTmodel, 180–240 s is not unusual. - Check the Ollama host itself for OOM:
If memory is at the limit, the Ollama container is OOM-killing the loaded model. Either reduce the model size (
docker stats ollama --no-streamgemma4:e4binstead ofgemma4: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):
- Add a second runner. Already provisioned in
docker/docker-compose.ymlasgitea-runner-2; the only remaining step is the one-time registration token (seedocs/gitea-actions.md §2aand INB-33). The historical workflow then runs in parallel on the second runner, freeing the first runner for the live cron. - 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. - 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.
- In the Gitea UI: repo → Actions →
- Bump
max_emailsper run (default 50). The workflow inputmax_emailscan be raised to 200, 500, etc. viaworkflow_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:
- Flushes whatever batch is currently in-flight to the checkpoint before exiting.
- Leaves NocoDB and SeaweedFS in a consistent state — every successful
process_donehas 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):
# 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:
# /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.
# 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):
NOCODB_API_TOKENfirst — 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.SEAWEEDFS_S3_ACCESS_KEYandSEAWEEDFS_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.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.GITEA_USER/GITEA_PASSlast — 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.
# 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
.gitignoreexcludes.envand.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
.emlarchive. - The Gitea service account used for the REST API (
GITEA_USER=ctoin.env.example) is scoped to thejc-capital/inboxosrepo. 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). Allinboxos:classifyandinboxos: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.pydoes 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
.emlis ~30–100 KB. 100 KB / email is the safe upper bound used in capacity planning. - 10 000 emails ≈ 1 GB of raw
.emlin 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), one Ollama container with
gemma4:e4bloaded. - Throughput observed (live): ~22 messages / 36 min on a single-runner + IMAP-backlog-saturated cycle. This is LLM-bound; each
inboxos:classify+ 1–2inboxos:extract-*calls adds up to ~90 s per email withgemma4:e4bon 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:e4bCPU, 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.emlS3 key. - UPSERT — "Update or insert."
pipelines.common.nocodb_upsertdoes this by doing a GET byMessage_IDand then a PATCH (if found) or POST (if not). There is no plainINSERTpath 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 usesmbox_stream.pyto read it as a byte stream, never materialising the full file in memory. - IMAP — Internet Message Access Protocol. The live pipeline uses
IMAPClientagainst Gmail'simap.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/v1via 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
.mboxare 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 thepapra_indexaudit 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-runmetrics.jsonlshape and the tail-and-summarize procedure (REQ-INB-23). - Backup and restore:
docs/operations-runbook.md §5.7 + §10(new in v1.1) — thebackup.sh/restore.shhelpers, 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:
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.mdPhase 5 status row is flipped to "executed". The 20 must-haves below were produced by the CIAgent plan at.ciagent/inboxos/plans/05-PLAN.mdand verified during the Wave 1 + Wave 2 + Wave 3 execution pass (see git log onmilestone/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_decidebehavioural asymmetry inlive_pipeline.py(P-7 in the research brief) was investigated and found to be a no-op: the live pipeline already callspapra_decideexactly once with the post-classifymoduleslist (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 asED-1in.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.mdand verified during the Wave 1 + Wave 2 + Wave 3 execution pass (see git log onphase/01-v1.1-execute).The 11 candidates are
[CANDIDATE]until the CPO authors the matching entries indocs/requirements.md; the must-haves below reference the candidate IDs as a planning aid. The.ciagent/inboxos/ROADMAP.mdPhase 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/inboxosrepo. 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.emlvia thes3_get_text_optionalhelper. - 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.