# Agent Operating Guide This is the operating guide for AI coding agents working **in** this repository. For self-hosting Reccado into a Cloudflare account (deployment, not code changes), see [`SKILL.md`](SKILL.md). For human contributor setup and PR expectations, see [`CONTRIBUTING.md`](CONTRIBUTING.md). Start with [`README.md`](README.md) for the product overview. This repository was implemented from `docs/IMPLEMENTATION.md` under senior-gated phases — do not spend context rediscovering facts already validated in `docs/validation/PHASE0_VALIDATION.md` and `docs/validation/PHASE1_VALIDATION.md`. Those two files are a **historical build-validation log**, not current operating docs; treat their concrete IDs/URLs as examples from a past run, not live state. ## Durable Invariants These hold regardless of which task or phase you're on. Do not weaken them without an explicit, informed decision from the user/supervisor. ### Data ownership and architecture - The **mailbox Durable Object is the only component allowed to decide canonical mailbox state**. D1 is a rebuildable cross-mailbox/control-plane index, never the source of truth — see `docs/ARCHITECTURE.md` ("Why Not D1 as Trunk"). - Queue payloads are **metadata only**. Never put raw MIME, parsed HTML/text bodies, or attachment content into a Queue message. - Raw MIME and attachments live in **R2**, never as Durable Object SQLite blobs. - MIME parsing happens in the Durable Object (or its ingest path), never in the `email()` handler itself — the handler's only job is validate → write raw bytes to R2 → enqueue metadata. - Ingest must be idempotent across Email Routing retries, Queue retries, and DLQ replay. A duplicate delivery (same Message-ID or same raw SHA-256) must never create a second message row. A reused Message-ID with a *different* raw body is a conflict to flag, not a silent overwrite. - Parser failures must never drop the email: keep a message row with `parse_status='failed'` and the raw R2 key preserved, and surface an ops event. ### Outbound sending - **An agent (or any automated caller) may draft and summarize mail, but must never send without explicit human confirmation.** The `request-send` → `confirm-send` flow with an idempotency key is the only sending path; there is no shortcut. This is a security boundary, not a UX preference — see `docs/ARCHITECTURE.md` ("Authentication and Security Model") and `SKILL.md` ("Hard invariant"). Repeated `confirm-send` calls with the same idempotency key must send at most once. ### Legal / provenance - Do not copy source, schema, text, or file structure from `mailflare`, `agentic-inbox`, or any other external inbox implementation into this product. External repos may be read only for behavior/pattern understanding and attribution notes — reimplement from first principles. - If evaluating an external reference implementation (clone it to verify behavior, deploy it to compare), clone only to a disposable path (e.g. `/tmp`), never import its code, deploy only under a disposable Worker name, and treat real edge-auth enforcement (an unauthenticated request must `302` to the provider's login page) as the only acceptable evidence that auth actually works — an app-level "not configured" message is not sufficient. ### Cloudflare resource safety - Do not modify production Cloudflare resources. Use only dev/disposable resources for exploration and validation. - Document every Cloudflare resource you create (name, ID, URL) in your validation evidence. - Delete disposable Cloudflare resources before finishing a task, unless the validation evidence needs to persist and the supervisor explicitly approves keeping it. ### Tooling - The Workers Vitest harness is configured in `vitest.config.ts` (via `@cloudflare/vitest-pool-workers`, reading `wrangler.jsonc` `env.dev`). Do not remove it or fall back to loading `vite.config.ts` for tests. - Use `rg` for search. Avoid broad full-repo reads unless the task genuinely requires it. - Use normal editor tooling (`apply_patch` or equivalent) for source edits; do not generate repository files via ad hoc shell heredocs, except for temporary evidence files kept outside the repo. - Do not commit unless the user/supervisor explicitly asks, or the current instruction says to create a checkpoint commit. ### Evidence and reporting discipline - Never write "works" or "seems fine" without evidence. If something cannot be validated, stop and report `FAIL` with the exact blocker — don't guess. - Every validation/gate result should include: explicit `PASS`/`FAIL`, the commands run and meaningful output snippets, files changed, Cloudflare resource names/IDs touched, URLs tested, example payloads (for email/queue/API/WebSocket/scheduled-handler work), logs or screenshots when relevant, the duplicate/idempotency result when relevant, and known failures with whether they're blocking. - End an agent run that did implementation work with a handoff in this shape: ```text RESULT: PASS|FAIL SCOPE: FILES CHANGED: - path COMMANDS: - command -> key output RESOURCES: - name/id/url EVIDENCE: - concise bullets BLOCKERS: - none, or exact blocker NEXT: - recommended next step, not implementation beyond scope ``` ## Build/Process Notes This section is current-state choreography — how to start a session and what's true *right now*. Unlike the invariants above, these are expected to go stale; if you notice a mismatch with the actual repo state, fix this section rather than trusting it blindly. ### Current baseline - Phase 1 (Tier A inbox) is senior-validated; see `docs/validation/PHASE1_VALIDATION.md` for the historical record. The transactional API (Phases 2–4 of `docs/plans/transactional-api.md` — HMAC/pepper API keys, auth + owner-gated key/template admin, `POST/GET /v1/.../transactional/...` with Bearer auth, mandatory `Idempotency-Key`, scopes, quotas, limits, test-key rejection for sending, `unknown` → no auto-retry, redacted logs, non-authoritative D1 projections) is implemented in `src/lib/transactional-*`, `src/do/transactional-*`, D1 migrations `0006`/`0007`, and covered by `tests/unit/transactional-*.test.ts` + `tests/integration/transactional-*.test.ts`. The MCP endpoint (`/mcp`, Better Auth OAuth + owner registry, read/search/draft only) is implemented in `src/mcp/*` (no send tool). Tier B proper (Workflows, EmailAgent drafting, RAG/Vectorize, AI Gateway) has not started — do not claim it. Cloudflare Email Sending lifecycle events are consumed by the `inbox-mcp-email-events` Queue and hard-bounce/complaint suppressions are enforced in the mailbox DO. The per-domain Email Sending event subscription is now created and verified by `pnpm setup:sending` (wrangler ≥ 4.127.1 exposes `--source email.sending`), checked against the live provider lists by `pnpm doctor --cloud`, and its actual liveness is reported per sending domain in `/api/health` → `dependencies.sendingFeedback` and on each transactional status response as `deliveryFeedback`. A domain with no subscription produces no events at all, so **never read the absence of a delivery event as evidence about a message without checking that domain's feedback liveness first** — see `src/lib/feedback-liveness.ts`. - Deploys go through `scripts/deploy.ts` (`pnpm run deploy:dev` / `pnpm run deploy`, `--dry-run` to preview): build, then overlay the gitignored `wrangler.generated..json` onto `dist/server/wrangler.json`. The generated file wins only for the fields it owns (vars, D1 id, sender allow-list, a `setup:domain` route) — `scripts/lib/deploy-config.ts`; `setup:domain` and `setup:cloud` use the same overlay. Never deploy with raw `wrangler deploy --env `. - Transactional API current gap: no simulated delivery sink for test keys; test keys are rejected by the production send path. `reconcileStaleTransactionalRequests` is wired into the hourly cron and an auth-protected operator endpoint, and unknown outcomes remain manual-review-only. - A security-hardening pass on top of Phase 1 is current/recent work: debug endpoints fail closed by default, attachment/raw downloads get hardened response headers, dev-data seeding requires explicit opt-in, an optional `OWNER_BOOTSTRAP_EMAILS` owner bootstrap exists, inbound size is capped, and mutating `/api/*` routes get an Origin-check CSRF defense. See `SECURITY.md` for the current posture and `CHANGELOG.md` for what's landed. - The web perimeter is **Better Auth inside the worker** (Better Auth 1.7.2): `/login` with e-mail OTP (registration closed to the owner registry ∪ `OWNER_BOOTSTRAP_EMAILS`), pairing-code rescue via `wrangler d1 execute`, `/mcp` over OAuth + `jwt()`, session cookie cache (5 min) with a confirm-send bypass, and migrations `0016`–`0018`. `BETTER_AUTH_SECRET` is set for the maintainer's dev environment; auth fails closed outside `localhost` when it is unset. `pnpm setup:auth` machine-generates and uploads the secret and offers the WAF rate-limit rule for `/api/auth/*`; `scripts/setup-access.ts` and `scripts/smoke-access.ts` are deleted. - D1 **is** in the inbound/outbound hot path as the cross-mailbox index (`message_index`, `ingest_events`, `outbound_sends`, `ops_events` are written on every ingest/send; transactional key/request projections are written on admin ops and send outcomes) — it is not authoritative (the mailbox Durable Object is), but it is no longer merely "bound and unused." - For control-plane `/api/*` access from the terminal (creating domains, mailboxes, aliases, templates, API keys), use `pnpm operator login --env --host --email ` and the session helpers in `scripts/lib/operator-session.ts` — not a browser, and not hand-minted pairing codes plus `curl`. Run `pnpm operator logout --host ` when done. - To onboard a whole product (sending names, routing rules, domain/mailbox/aliases, templates, keys into 1Password), use `pnpm onboard --env --manifest ` — dry-run by default, `--apply` to perform; see `docs/OPERATIONS.md` ("Onboarding a product"). It never deploys. - For the Telegram bridge (bot identity, rebinding the chat, forum topics per mailbox, delivery checks) use `pnpm operator telegram status|rebind|topic|topics` and `pnpm smoke:telegram` — never raw D1 on `runtime_config` / `telegram_topics`; see `docs/OPERATIONS.md` ("Moving the Telegram bridge to a forum"). - Do not re-run a full Cloudflare-resource preflight unless the assigned task actually touches Cloudflare resources or auth. Do not advance beyond the assigned spike/milestone/task — validation gates are blocking. ### Known dev Cloudflare resources (maintainer's environment — placeholders, fill in your own) - Account: ``. - Dev Worker: `reccado-dev`. - Dev URL: `https://reccado-dev..workers.dev`. - R2 bucket: `inbox-mcp-raw-dev-eu` (created with `--jurisdiction eu`). - Queue: `inbox-mcp-inbound-dev`. - DLQ: `inbox-mcp-inbound-dlq-dev`. - D1 database: `inbox-mcp-index-dev-eu` (created with `--jurisdiction eu`), id ``. - Email Routing rule: `test@example.com -> reccado-dev`. - Dev test mailbox: `test@example.com` resolves to the fixed dev-fixture id `DEV_TEST_MAILBOX_ID` (`src/db/seed-dev.ts`, seeded by `pnpm seed:dev-id`), so the dev server, the debug endpoint and the smoke scripts all agree without talking to each other. Real mailbox IDs are **random**, assigned by the `INSERT` that creates the row, with D1 as the only source of truth (`src/lib/mailbox-id.ts`) — nothing derives them from a secret. The literal `mbx_test` you'll see in some smoke commands (e.g. `pnpm smoke:ws .../mailboxes/mbx_test/ws`) is just an arbitrary Durable Object name used for WebSocket echo testing and is unrelated to D1-routed mailbox identity. ### Fast preflight Run only this baseline preflight at the start of a task: ```sh git status --short --untracked-files=all git log -1 --oneline node -v pnpm -v pnpm wrangler --version ``` If the task touches Cloudflare resources, add: ```sh pnpm wrangler whoami pnpm wrangler deployments list --name reccado-dev pnpm wrangler r2 bucket list pnpm wrangler queues list pnpm wrangler d1 list ``` Do not list every Cloudflare service on every task. Do not browse Cloudflare docs unless the assigned gate explicitly requires verifying a current limit or API shape. ### Current verified commands These are expected to pass before checkpointing foundation changes: ```sh pnpm wrangler types --env dev pnpm run build pnpm exec tsc --noEmit pnpm test -- --run pnpm smoke:ws wss://reccado-dev..workers.dev/api/mailboxes/mbx_test/ws pnpm smoke:email:local http://localhost:3001 fixtures/mime/simple-text.eml ``` The local smoke commands require a running dev server. If port `3000` is occupied, Vite may bind to `3001` (or another port); use the port printed by `pnpm dev`. ### Implementation reading discipline - Read only the assigned section of `docs/IMPLEMENTATION.md` plus nearby definitions needed for that section, and `docs/ARCHITECTURE.md` only for the architecture decisions relevant to the assigned work. - Prefer existing local patterns in `src/server.ts`, `src/do/mailbox-do.ts`, `src/cloudflare/*`, and `src/lib/*` over inventing new conventions.