Open bug report

Tenant apps can't reconcile replies (/state needs fleet adminToken) + 2 related inbound gaps

Drew Owens · 10d ago

adom-courier — issues surfaced building adom-delegate (2026-07-10)

From: the adom-delegate build (drew2). For: the adom-courier maintainer session. TL;DR: adom-delegate wants the courier human-in-the-loop reply loop (nudge a delegatee in Chat → their reply auto-updates the delegation). We deferred the entire inbound direction to v2 because, as courier is specified today, a tenant app cannot implement the "never lose a reply" guarantee the skill itself promises. Three concrete gaps below, each with the exact contradiction in the current adom-courier skill doc, plus a proposed fix. Outbound (POST /requests) is fine and is what v1 ships.

Doc references are to ~/.claude/skills/adom-courier/SKILL.md as installed 2026-07-10 (pkg v0.2.3).


Issue 1 — "reconcile from /state" is impossible for a tenant app (the blocker)

The contradiction, in the skill's own words:

  • Line ~45: *"Delivery is best-effort + observable (no retry). … the courier won't re-send; the app should reconcile from /state."* — so reconcile-from-/state is the prescribed way an app recovers a reply its receiver missed.
  • Line ~69: "/state+/reset need adminToken; /health open." — and the config block (line ~60) shows adminToken is the fleet-wide secret (it gates /state and /reset for every app on the shared courier).
  • A registered app only receives apps["<app>"] = { dispatchUrl, apiKey, dispatchToken } (line ~62). It gets no adminToken.

So: the only documented recovery path (GET /state) requires a credential the app must never hold. If a tenant app did hold adminToken, one app's compromise would expose read + /reset of the whole fleet's Chat traffic — an unacceptable blast radius. Result: the app's promised "reconcile" is unimplementable without over-privileging it.

Reproduction (what an app hits): register an app, send a /requests, take the receiver down, have a human reply. The event is now delivered:false / held in /state with an undelivered count — exactly as designed — but GET /state with the app's apiKey returns 401. The reply is sitting in the courier, visible only to admin.

Proposed fix: add an app-scoped state view gated by the app's own apiKey: GET /state?app=<me> (or GET /apps/<me>/state) returning only that app's open requests + recent response events (delivered/held/undelivered), authorized by apps[me].apiKey with the same timingSafeEqual + app-match check already used for /requests. That makes the skill's own "app should reconcile from /state" instruction actually achievable, with zero fleet exposure.


Issue 2 — held replies (and their attachment bytes) aren't retrievable by the app

Dispatch is best-effort/no-retry. When the receiver is down (e.g. an app whose serve runs in an ephemeral container), the event is marked held/undelivered. The attachment bytes are only ever present as base64 in the live dispatch POST (line ~39/102). A held event in /state exposes attachment metadata (name/contentType/size/path) but the app has no authorized way to fetch the bytes after the fact — and even /state is admin-only (Issue 1).

Concrete loss: delegatee replies "done" at 9pm with a photo after the app's container has spun down → dispatch times out → no retry → next morning the app can't recover the reply or the photo. For a delegation tracker whose whole value is "zero manual data entry," this silently drops the payload the user cares about most.

Proposed fix (any one, in preference order):

  1. App-scoped replay: GET /apps/<me>/events?since=<cursor> (apiKey-gated) that returns held + recent events including a way to fetch attachment bytes (either inline base64 on demand, or a short-lived signed GET /attachments/<requestId>/<n> the app can pull). Pairs with Issue 1.
  2. Bounded dispatch retry with backoff for delivered:false events (e.g. a few tries over some minutes) so a briefly-down receiver still gets the reply without any app-side polling.
  3. Per-app durable outbox the app drains and acks (at-least-once + app-side idempotency, which adom-delegate already has via a (request_id, turn)-style key).

Issue 3 — the model is "reply in a space," not "reply from the person I asked"

POST /requests {app, space, itemKey, text} posts as Marv into a pre-configured space and correlates the first human reply in that thread; the dispatch carries responder:{name,email,userId} (lines ~32, ~39). Two consequences an app-level design must not assume away:

  • You cannot nudge an arbitrary email — the delegatee has to already be in a space registered in courier config. adom-delegate keys delegations on delegate_email; there's an impedance mismatch between "who I delegated to" and "which space Marv posts in."
  • responder.email is whoever replied first in the thread, not necessarily the person you asked. Any app that auto-advances state on a reply can be driven by the wrong person (a bystander in the space), or by a crafted reply — a social-engineering / spoofing surface. (adom-delegate's stance: never auto-advance status purely from reply text; require an explicit, verified signal.)

Proposed fix (docs + optional feature):

  1. Document the semantics loudly: "responder ≠ necessarily the person you asked; the ask targets a space, correlation is first-reply-in-thread." Apps need this to design safely.
  2. Consider a first-class expectFrom: ["email@…"] on /requests (server-side enforced, reusing the existing inboundAllowlist fail-closed mechanism at line ~74) so an app can say "only accept a reply from this delegatee," and courier ignores/held-flags replies from anyone else.

What adom-delegate ships in the meantime (v1)

  • Outbound only: nudgePOST /requests with Authorization: Bearer <apiKey>, itemKey = delegation id, allowlisted space, sanitized/templated text (a crafted title can't become a Marv-badged phishing payload). Records last_nudge_at + courier_request_id. Fails soft.
  • Inbound is a stub: POST /courier/dispatch returns 501; a courier_inbound_stub marks where the receiver lands. No adminToken anywhere. No auto status-advance from reply text.

When Issues 1 (+ ideally 2) are addressed in courier, adom-delegate can implement the inbound loop against the app-scoped /state (poll/reconcile with its apiKey) — no admin token, no dependence on receiver uptime, attachments recoverable. That's the unblock.

Suggested courier work items

  • [blocking for adom-delegate] App-scoped GET /state (or /apps/<me>/state), apiKey-gated (Issue 1).
  • Held-event replay incl. attachment-byte retrieval, apiKey-gated (Issue 2) — or bounded dispatch retry.
  • Doc: responder-vs-asked semantics; optional expectFrom allowlist on /requests (Issue 3).

0 Replies

Log in to reply.