- 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 atPOST /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 [email protected]
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:
- 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. - 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.
- A refusal answers with a reason.
401for a bad signature,409for a duplicate delivery,422for a payload we cannot map,403naming the capability the source lacks. A sender cannot fix what it is not told. - 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
- Which domain receives?
zeitforge.appneeds 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. - 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.
- 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.