- Feature Name: `inbound` (what may write to a forge from outside it —
  webhooks, and the email reply that is one)
- Start Date: 2026-09-09
- Status: Draft for review
- Depends on: RFC 0001 §8 (OLTP), §9 (the ops seam), Principles 2, 3, 5;
  RFC 0004 §4 (capability letters — an inbound source is a principal);
  RFC 0005 (event vocabulary); RFC 0008 (roles); RFC 0011 (attention:
  notify owns transport, quarry owns policy)
- Upstream: notify already ships `POST /v1/inbound/email` (X-Inbound-Secret),
  per-source ingest at `POST /v1/sources/{id}/events` (SourceKey), verified
  inbound domains, thread correlation and an HMAC reply-token minter. None of
  it is configured on zeitforge; none of it blocks Phase 1.

## Summary

quarry can be written to from outside itself in exactly one way today: its
API, with an account's token or password. Everything else points outward —
webhooks fan out, notifications fan out, mirrors pull. This RFC says how
something arrives.

The rule it proposes is one sentence: **notify owns the transport and the
correlation, quarry owns what an inbound message becomes and who it counts
as.** That split already exists for outbound (RFC 0011) and the same seam
carries inbound with nothing new invented.

The first thing built on it is replying to a notification email and having
the reply land as a comment. That case needs no new identity model, which is
why it is first.

## The question every inbound request must answer

Not "is this request well-formed" but **on whose behalf is it writing**.
A forge is a record of who did what; an inbound path that cannot answer
that either lies in the record or must refuse to write. There are exactly
three answers, and they get three different treatments.

| | authenticates | attributed to | may create |
|---|---|---|---|
| **Reply token** | the person AND the thread | that account | what the account may create |
| **Source key** | the integration, not a person | the source, named | what the source's letters allow |
| **Neither** | nothing | — | nothing |

The third row is the important one. A cold email to `tickets@zeitforge.app`
carries a `From:` header anyone can forge, and DKIM proves only that *some*
mail server signed it, not that its sender is the person whose account uses
that address. Cold email does not create content in v1. It is not a
limitation to be lifted later by trying harder; it is what the evidence
supports.

## An inbound source is a principal

The temptation is a second authorization scheme: allowlists, scopes, a
per-integration permission bit. quarry already has a principal model —
capability letters (RFC 0004) — and an inbound source should be one.

A source row therefore holds a capability string, exactly as an account's
grant does. A source with `n` may open tickets and nothing else; a source
with `nc` may also comment; a source with no `C` cannot reach chat however
its payload is shaped. This means the existing gate answers inbound
questions with no new code, the People page can show a source beside the
accounts, and revoking a source is revoking a grant.

It also means the answer to "what can this webhook do to my repository" is
readable in the same vocabulary as everything else, which is the whole
argument for having one vocabulary.

## Where things go

**To notify** — anything whose destination is a notification: feed sources,
inbound email correlation, delivery receipts. It already has the machinery
and the credentials, and quarry gains nothing by holding them.

**To quarry** — anything that becomes a forge artifact: a ticket, a comment,
a forum reply, a landing remark, a CI verdict. These are capability
decisions about repository content, and that is quarry's whole job.

**Email reply is both, and that is the interesting case.** The mail arrives
at notify because notify minted the token, holds the thread correlation and
has the verified domain. notify authenticates it and resolves it to
`(account, thread)`. Then it calls quarry — through the `Responder` seam
that already exists in `internal/emailloop` — and quarry decides what the
turn becomes and whether that account may write it. Neither side does the
other's work.

```
Cloudflare Email Routing
  → Worker (parses, POSTs)
    → notify  POST /v1/inbound/email      X-Inbound-Secret
      verify reply+<token>@ → (account, thread)
      → quarry POST /api/v1/inbound/reply  source key + token claims
        gate on the account's letters
        → forum reply | ticket comment | landing remark
```

## The quarry endpoint

```
POST /api/v1/r/{repo}/inbound/{source}
X-Quarry-Inbound: sha256=<HMAC-SHA256(secret, timestamp + "." + body)>
X-Quarry-Inbound-Timestamp: <unix seconds>
X-Quarry-Delivery: <the sender's own id for this delivery>
```

Deliberate choices, each with a reason:

**The same signature scheme as outbound.** Operators already verify
`X-Quarry-Signature` as `sha256=HMAC-SHA256(secret, body)` on the way out
(`internal/webhook`). Inbound signs the timestamp with the body so a captured
request cannot be replayed a day later, and otherwise is the same scheme with
the same words. One thing to learn, not two.

**Secrets stored as the outbound ones are** — minted once, shown once,
never retrievable, rotatable with an overlap window (`RotateWindow`, an
hour). The rotation path exists already and inbound reuses it rather than
inventing a second lifecycle.

**Idempotency is required, not optional.** Every real sender redelivers:
Cloudflare retries, Resend retries, our own retry does. `X-Quarry-Delivery`
is stored and a repeat is answered `200` with the original outcome and no
second artifact. Without this the first network blip turns one email into
three comments.

**Bounded before parsed.** A body cap (64 KB, the tickets' own limit),
a timestamp skew window (five minutes), and one lane per source using
`internal/work` so a source in a retry storm cannot starve ingest — the
lane machinery exists and this is exactly the load it was written for.

## What an inbound message becomes

| arrives as | becomes | attributed to |
|---|---|---|
| reply to a forum notification | a reply on that thread | the replying account |
| reply to a ticket notification | a comment on that ticket | the replying account |
| reply to a landing notification | a remark on that landing | the replying account |
| source event, kind `ticket` | a ticket | the source, named in the body |
| source event, kind `checks` | a CI verdict on a check-in | the source |

The last row matters more than it looks: it is how a CI system that is not
dagnats reports into quarry, which is the honest answer to "can I use my own
CI" and today has no answer at all.

An artifact created from an inbound message says so. A reply carries the
account as its user because the token proves it; a source-created ticket
carries the source's name in its body, never a member's, because nothing
proved a member wrote it.

## It must never fail silently

This is the rule the day this was written earned three times over: notify
accepted triggers for four days and delivered none of them; alerts addressed
a subscriber that did not resolve and reported success; a push step's title
was dropped and every notification said the same word. Each was silent, and
each cost days.

So, in order:

1. **Every delivery is recorded** — accepted, refused, or failed — in a table
   shaped like `webhook_delivery`: source, kind, status, duration, error,
   attempt, and the bytes received. Same 30-day retention, same 16 KB body
   cap, for the same reasons.
2. **The record is visible** on the repository's settings page, beside the
   outbound hooks and in the same folded-log shape, because a person
   debugging an integration should not have to learn a second place to look.
3. **A refusal answers with a reason.** `401` for a bad signature, `409` for
   a duplicate delivery, `422` for a payload we cannot map, `403` naming the
   capability the source lacks. A sender cannot fix what it is not told.
4. **Repeated failure pauses the source** and raises a fault, which now
   reaches every admin's inbox. A source that has failed its last N
   deliveries is broken, and the forge should say so before the operator
   notices in a month.

## What v1 will not do

- **No content from cold email.** Covered above: the evidence does not
  support attribution.
- **No two-way chat bridge.** Inbound chat writes messages into a room where
  members expect the roster to mean something; that needs the identity story
  syndication was parked on.
- **No attachment persistence.** notify accepts attachment metadata and does
  not store bodies; quarry should not be the first to, and a link is enough.
- **No inbound to a mirror.** A mirror is a copy of somebody else's history;
  writing to it invents divergence nobody asked for.

## Phases

Each ships alone and is useful alone.

**Phase 1 — reply to a notification.** The token path end to end: verified
inbound domain, `Reply-To` on outbound notification email, the responder,
and forum replies only. Proves the seam with the narrowest surface. Needs
Cloudflare Email Routing pointed at a Worker, and a verified domain row in
notify; no new identity model.

**Phase 2 — the delivery record and its page.** The table, the settings
section, the reasons, the pause. Deliberately before third-party sources:
the first thing an integration needs is to see why it failed.

**Phase 3 — source keys and the generic endpoint.** Source rows with
capability letters, the signature scheme, idempotency, the lane. Ticket
creation first, because it is the one with the least to get wrong.

**Phase 4 — external CI verdicts.** `checks` events against a check-in, so
a forge with its own CI is a supported shape rather than an accident.

**Phase 5 — an agent mailbox**, if wanted. notify's agents plane exists and
its `mesh` runtime publishes over NATS; quarry would answer on that subject
with what it knows — the index, a landing's state, what CI did. This is the
one phase that is a product decision rather than plumbing, and it should not
be started until Phases 1–2 have run in anger.

## Open questions

1. **Which domain receives?** `zeitforge.app` needs MX records pointed at
   Cloudflare Email Routing, which affects the domain already sending mail.
   A subdomain — `mail.zeitforge.app` — isolates the change and is what I
   would choose, at the cost of uglier reply addresses nobody reads.
2. **Do source-created tickets need a member to adopt them?** A ticket
   nobody owns is a ticket nobody triages. Adoption is a small feature and
   might belong in Phase 3 rather than later.
3. **Should a reply that fails to map bounce?** Silently dropping a reply
   somebody typed is the rudest possible failure, and an automated bounce is
   the only way they learn. It costs an outbound email on a path that has
   just proven it can send.
