Skip to content
quarry Artifact [702f67aa38]
Login

Artifact [702f67aa38]

Artifact 702f67aa38b0afb16bc30cccab8d1f0b48464cb9f11f5f39a7a66ccf30b7d386:


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 (subscriber, transaction), then puts an email.reply event on its delivery stream — the same stream every delivery receipt rides. quarry is one endpoint on that stream. It reads its own transaction id back, decides what the turn becomes and whether that account may write it. Neither side does the other's work, and notify never learns quarry's routes.

mail.zeitforge.app :25  (notify's embedded receiver)
  verify reply token → (subscriber, transaction)
  → delivery stream: {"type":"email.reply", subscriberId, transactionId,
                      text, messageId, ...}
    → quarry POST /api/v1/notify/events   X-Notify-Signature over ts.body
        transactionId → thread   (forum-reply:<post>:<who>, mention:forum:…)
        subscriberId must equal <who>; gate on their letters
        → forum reply, as that account

notify mints the endpoint's secret and shows it exactly once, when quarry registers the endpoint at startup (or signs one that predates signing); quarry keeps that copy and verifies every delivery against it. A delivery that does not verify is refused with 401; one notify has already delivered (same messageId) is answered 200 from the record and posts nothing twice; a reply that cannot be mapped or that the account may not write is refused 422/403 with the reason, and recorded so a retry is not a second chance to be wrong. Only a transient failure answers 5xx, which is the one answer that asks notify to try again.

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

Phases

Each ships alone and is useful alone.

Phase 1 — reply to a notification. The token path end to end: notify's embedded receiver on a verified inbound domain, Reply-To on outbound notification email (a step's "reply": true), quarry registered on the delivery stream, and forum replies only. Proves the seam with the narrowest surface; no new identity model. notify signs each delivery with the endpoint's secret over timestamp.body (notify 0.1.1); a forge with no secret on file refuses everything, because the endpoint is public.

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? Settled: mail.zeitforge.app, MX to the host notify's receiver listens on, isolating the change from the domain that already sends mail.
  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.