docs: propose ADR 050 — the Dev Inbox capability - #710
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
There was a problem hiding this comment.
Pull request overview
Proposes ADR 043 to define a future server-side “capture” transport for outbound email/OTP messages so end-to-end tests (via @zitadel/testing) can reliably observe verification codes / links without relying on SMTP sinks or log scraping.
Changes:
- Adds proposed ADR 043 describing a transport abstraction with an opt-in, bounded capture buffer and an operator-authenticated read API.
- Updates the ADR index to include ADR 043.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| docs/adrs/README.md | Adds ADR 043 to the ADR index table. |
| docs/adrs/043-test-visible-email-otp-capture.md | New proposed ADR specifying the test-visible capture contract for future email/OTP delivery. |
…cceptance checklist
# Conflicts: # docs/adrs/README.md
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Summary
Docs-only ADRs for nextgen audit logging (relational SoT + semantic wide
events):
- **[ADR 048](docs/adrs/048-wide-events-internal-audit-primitive.md)** —
internal `events` table, categories, Path A (batched request events) +
Path B (`AllStatements` / `InsertEvent`), deny-by-default PII +
`x-audit`
- **[ADR 049](docs/adrs/049-events-api-retention-export.md)** — unified
`GET /events`, time-only retention, first-class managed sinks,
project-scoped get
Also updates ADR index, ADR 028 Related, resource-map, api README, and
`user-schema.md`.
### Review decisions (latest round)
- **Pre-claim (a):** emit/store always; gate list/get/shipper until
claim (ADR 046)
- **No `resource_scope_index` for events;** `GET /events/{id}` is
project-scoped
- **Retention:** time-only (not sink-gated); `events_purged_undelivered`
metric when aging out undelivered rows
- **Sinks:** first-class CRUD, managed `sink_…` ids; ≤1 deployment
stdout + ≤1 deployment webhook + ≤1 project webhook (additive)
- **Path A:** bounded buffer, drop new enqueue + metric, retry/backoff,
recompute `wait`
- **`x-sensitive`:** kept complementary to `x-audit` (still used by
OpenAPI/console/config)
**Note:** [#710](#710) also
claims ADR 048 (Dev Inbox) — number collision if both merge; coordinate
renumber.
## Validation
- Docs-only; no `moon ci` / Go tests run
- Grep: no sink-gated purge; pre-claim store+visibility; events not
indexed in RSI
## Release notes / changeset
Docs-only — **no changeset** per [`.changeset/README.md` decision
table](.changeset/README.md#decision-table).
## Notes
PR title remains conventional `docs:` (allowed by
`.github/semantic.yml`).
<!-- CURSOR_AGENT_PR_BODY_END -->
closes #350
<div><a
href="https://cursor.com/agents/bc-ba77f572-bfcf-4606-86d6-51ee221d365e"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a> <a
href="https://cursor.com/background-agent?bcId=bc-ba77f572-bfcf-4606-86d6-51ee221d365e"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a> </div>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Silvan <adlerhurst@users.noreply.github.com>
Co-authored-by: Livio Spring <9405495+livio-a@users.noreply.github.com>
# Conflicts: # docs/adrs/README.md
Why
Flows that deliver a secret out of band — email verification, email-OTP login, password recovery (ADR 038) — cannot be tested end-to-end, and won't be when they arrive, unless tests can observe what the server sent. The server has no email delivery subsystem at all yet, which makes this the cheapest possible moment to fix the contract. And the platform overview already narrates the capability this ADR specifies: the scratch dashboard's dev inbox and the "Dev Inbox / Bring Your Own / Managed" delivery picker. This ADR is the server-side contract behind that story.
What changed
ADR 050 (Proposed): The Dev Inbox — Captured Outbound Messages. Rewritten from the initial "capture transport" draft after two review inputs — a competitor scan (Supabase, Resend, Clerk, Firebase emulator, Stytch, Better Auth) and a codex review — into a product-capability framing:
capturestores messages in the project/environment-scoped Dev Inbox (ADR 035 scoping, ADR 036 operator plane).schema_version) carrying typed, purpose-discriminatedartifacts(code/link — populated by composition from the flow engine's own state, never parsed from templates;variablesstays a non-API record of render inputs) plusrenderedforms for humans. Two timestamps with distinct meanings:expires_at= artifact/challenge validity (guaranteed shared per record),retained_until= store retention.zitadel dev-inboxJSON commands, and@zitadel/testing(cursor()+waitForMessage({after, to, purpose, artifact})— the artifact-key predicate makeswaitForCode()wait for a code-bearing record, since a purpose may produce link-only messages; keys, never values, appear in list metadata) — no test-levelclear(), which would be cross-worker destructive under the kit's shared-instance parallelism. Waits are bounded and cancellable (timeoutMs,AbortSignal, typed timeout error echoing the resolved filters), and every consumer call binds an explicit environment (InstanceHandle.environmentId; CLI--envwith deterministic resolution, echoed in the envelope).zitadel start/startLocalZitadel()enable it,connectZitadel()discovers it; no tee mode.provider_requiredenvironment policy (ADR 035 defers env-classes, so the ADR defines the enforceable bit instead of a classification) — enforced at deploy, on every provider/policy mutation, at startup, and at send time (a send without a working provider fails loudly, never falls back to capture); one deliberate divergence from the overview's current "Dev Inbox (default)" for claimed production, flagged in the ADR.zitadel dev-inbox open, with the transfer specified: fragment-carried token (never in access logs), POST exchange,history.replaceState,Referrer-Policy: no-referrer, log redaction.zitadel startnever emits a bearer-bearing URL — it reportsavailablevsconfiguredcapability state and points atopenonly via a human-facingnext_actionshint, nevernext_commands(which agents execute by contract, and which would also fail pre-setupwhen no secret exists). The scratch dashboard's anonymous first-visit cookie is called out as insufficient for inbox content — a second overview amendment.variablesalone would be theater whilerendered.*embeds the same secret; the full message is a separately authorized read. Any durable backend encrypts message content at rest (ADR 029); only the ephemeral in-memory store is exempt.@zitadel/api-mock), admin-mint (hash-only storage + flow-binding), kit-embedded SMTP listener.Also in this branch's history: renumbered as
mainclaimed numbers in flight — 042 → 043 → 044 → 046 → 047 → 048 → 050 (042 scaffolded-file ownership, 043 framework version floors #713, 044+045 scaffold posture and copy overlays #714, 046 claim lifecycle v2 #627, 047 dialect-owned identifier generation #663, 048+049 wide events #459) — withmainmerged in at each collision; the file is now050-dev-inbox.md.Validation
Docs-only.
Review notes
Status is Proposed — a conversation starter. The first review round (livio) recorded a lean on every open question, now folded into the ADR: composition via a typed flow intent + async notification boundary (v4-shaped enqueue → compose → capture|deliver, wide-events/River as the bus); durable store only on multi-replica / shared scratch inbox / unread-survives-restart (ADR 029 bar applies equally to queue payloads); default-deny support access until #333; optional explicit
consumed_atover auto-consume; one operator port with an optional path-prefix kill-switch. The same round added a normative purpose × artifact-key table, the static email-dependence detection signal for theprovider_requiredgates, and an "On acceptance" checklist carrying both platform-overview amendments (claimed-production delivery default; scratch-inbox session acquisition). The leans harden into decisions when the ADR moves to Accepted.A later
mainmerge brought #722's ADR 036 amendment (credential exposure contracts), which governs exactly the fragment-carried handoff token this ADR specifies — the BFF section now carries an explicit conformance passage instead of a silent divergence: the exchange behaves as a credential-establishing operation (browser-attestedOriginenforced, so an exfiltrated token can't be exchanged from a foreign context), the minted session is inbox-scoped and can never become a login session, and the endpoint adopts ADR 036's PKCE-style proof binding for URL-transiting handoffs once that machinery ships.The #459 merge (wide events, ADRs 048/049) touches the composition lean, which named "wide-events/River" as the natural bus: ADR 048 defines wide events as an audit primitive (deny-by-default PII, export-only), so the lean now cross-references it and folds "can the audit stream double as the dispatch bus, or does the boundary ride a job queue with its own payloads" into the already-open event-shape question. ADR 049's events API stays on plain ADR 027 keyset cursors, so it does not collide with this ADR's high-water-mark cursor contract.