Result delivery — the external last mile
Audience: authors of agents that write a structured result to a Maidan thread
and want it to reach an external surface (a GitHub PR comment, a Slack message).
An external code-review waiter publishes a
maidan.waiter.result/1 envelope via set_thread_result.
This page is the interface contract between a result producer and Maidan. Read it alongside Integrating with Maidan.
Status — read this first
⚠️ Breaking change for existing producers
The envelope discriminator was renamed. Producers still sending the old value are silently not delivered.
Was Is now pi.waiter.result/1maidan.waiter.result/1pi.review.result/1example.review.result/1(aresult_kind)view_in_piview_urlA
schema/$typethis build does not recognize makesparse_waiter_resultreturnNone, which is inert by design — no delivery is attempted, and because an unrecognized envelope is not an error there is no warning, noskippeddelivery row, and nothing on the status API. A producer on the old string therefore goes dark rather than failing loudly. Update the discriminator; nothing else about the grammar changed.This rename is also the reason the fixture lock could not catch it: the same change renamed
crates/maidan-types/tests/fixtures/waiter_result_v1.jsonand edited its contents in lockstep with the parser, so the guard moved with the code instead of failing. Going dark silently is the cost, and it is why this notice exists rather than a changelog line.
Shipped. This page is the interface contract between a result producer
and Maidan. The grammar is frozen at maidan.waiter.result/1. Additive fields are
free; a change to the meaning of an existing field, or to the deliver_to shape,
requires a new schema value. The result_kind list facet shipped.
| Piece | State on main today |
|---|---|
set_thread_result / get_thread_result / ThreadResultSet event | Shipped. A result is durable and observable. |
| Slack + GitHub connectors (post, link tables, link management) | Shipped. |
| Durable retrying delivery queue + DLQ + replay | Shipped. |
| The per-workspace egress allowlist (the trust boundary below) | Shipped. POST/GET /workspaces/:wid/egress-targets + DELETE …/:tid, token:admin. |
A ThreadResultSet handler that delivers to deliver_to | Shipped. Fetch → parse → per-target allowlist check then enqueue. |
| Idempotent update-in-place | Shipped. Stored external_ref → update_*; GitHub recovery marker <!-- maidan:result:<thread_id> --> at byte 0. |
| Per-thread delivery status + replay | Shipped. GET /threads/:id/deliveries + POST …/deliveries/:did/replay + MCP list_result_deliveries / replay_result_delivery. |
| Inline per-finding PR review comments | Shipped. After the GitHub summary, a reviewed envelope with head_sha and usable findings posts POST /repos/{repo}/pulls/{n}/reviews (commit_id = head_sha, event: COMMENT, RIGHT, line = line_range.end). |
result_kind list facet | Shipped. Exact-match on the namespaced string — see Discoverability. |
Run lineage (parent_run_id) | Shipped. The producer's run_id is accepted as-is and homed on the thread. Not a delivery-routing field. See Run lineage. |
Producer loop: write deliver_to on the envelope; bless the destination once over
the allowlist; confirm where it landed with the status API. A perfectly correct
deliver_to can still deliver nowhere if the target is unblessed — that is a
normal outcome, not a producer bug.
Inline per-finding PR review comments are shipped.
380.1 pinned the line_range frame: file-absolute post-image
lines (the file as it exists at envelope head_sha), 1-indexed inclusive. On
GitHub that is the RIGHT side of the pull-request diff. 380.2 posts
POST /repos/{repo}/pulls/{n}/reviews after a successful summary comment, with
those coordinates and commit_id = head_sha — never the live PR head. 380.3
locks the remaining failure modes: replay after a 5xx review, dual-surface
envelopes, review errors that must not disable_link, a vanished envelope at
send time, and the projector kind-split. The summary comment path
is unchanged.
What Maidan reads from the envelope
Maidan is a tolerant reader. The result is opaque JSON owned by the producer; Maidan parses only the fields it routes on and ignores everything else. Adding a field never breaks delivery.
| Field | Required | How Maidan uses it |
|---|---|---|
schema | yes | Envelope discriminator. Must be maidan.waiter.result/1. An unrecognized value means no delivery is attempted. |
result_kind | yes | Which producer shape this is, e.g. example.review.result/1. Recorded; the list facet shipped (see "Discoverability"). |
status | yes | Delivery happens only on reviewed. Any other value delivers a short Maidan-authored failure notice instead — never silence, never a clean pass. |
deliver_to | no | The routing list. Absent or empty is valid and normal: thread-only, delivered nowhere. |
rendered | on reviewed | The delivery body. Producer-authored trusted markdown. |
summary | on reviewed | One line. The Slack body and the notification title. |
view_url | no | A backlink appended to every delivery. |
pr | no | A human back-reference echoed into the delivered body. |
head_sha | for inline comments | GitHub commit_id. 40- or 64-char hex. Absent or unusable ⇒ no inline review (the 379 summary comment still posts). Never resolved from the live PR head. |
findings | no | Two readers. Each usable finding needs file, body, and line_range. Any finding with severity exactly critical on a reviewed example.review.result/1 from a review-skilled producer writes request_changes (and arms k=1 if unset). A critical finding without file/body/line_range still arms the gate. Other finding fields stay in the stored JSON unread. |
run_id is not a delivery-routing field. Delivery parse still ignores it.
The producer's string is homed as parent_run_id on the thread — see
Run lineage. Everything else in the envelope —
corroboration, per_seat, seats, cost_usd, duration_secs, sandbox,
finding_count, diff_available — is carried through untouched. Maidan does
not interpret those for delivery.
The canonical Finding wire shape is the producer's and does not change.
Maidan stores the envelope byte-for-byte, then projects file /
line_range / body for the GitHub review POST, reads
severity for the close-gate adapter only, and reads run_id for
lineage only.
Run lineage
Wave 2 #28's lineage half. The waiter envelope already carries run_id (and
view_url). Until this cluster that string had no home in Maidan. The
field accepts the producer's value — Maidan does not mint a parallel id.
The authoritative fixture value is aa4dc966-0e09-44c3-b7a5-2d048b48b301 in
crates/maidan-types/tests/fixtures/waiter_result_v1.json.
run_id_from_payload extracts a string (trim; empty / whitespace / longer
than 256 bytes → ignored). It does not require schema = maidan.waiter.result/1.
parse_waiter_result still ignores run_id, so delivery routing is unchanged.
set_thread_result (REST PUT /threads/:id/result and the MCP tool) homes
the value as parent_run_id when present — best-effort, so a lineage hiccup
never undoes a stored result. A caller can also set it directly.
| Surface | Capability |
|---|---|
PUT / GET / DELETE /threads/:id/lineage | write/delete = thread:transition; get = workspace:read |
GET /workspaces/:id/run-threads?parent_run_id= | workspace:read (private-channel rows the caller cannot access are dropped) |
GET /workspaces/:id/run-occupancy?parent_run_id= | workspace:read (empty / unknown run → zeros, not 404) |
MCP set_thread_lineage / get_thread_lineage / list_run_threads / get_run_occupancy | the twins of those routes |
Nested occupancy is the two-clocks partition (queued / claimed / working /
blocked) of every open workspace thread that shares parent_run_id.
F7 mute (maidan_thread_mutes) is a different table and is not consulted —
a muted nested thread still counts.
Following one member's occupancy and the manager digest shipped later (Cluster 404): see Integration — the waiter loop.
Inline findings
Frame of reference (pinned 380.1). findings[].line_range is a 1-indexed
inclusive span (start..=end) on the post-image file at head_sha —
the file as that commit left it, not a diff-hunk relative offset. On GitHub's
split view this is RIGHT. LEFT is deletions that no longer exist in the
after-state; a finding that quotes a line in the resulting file is never LEFT.
GitHub mapping for POST /repos/{repo}/pulls/{n}/reviews comments[]:
| Envelope | GitHub |
|---|---|
file | path |
line_range.end | line (last line of the range) |
line_range.start when start != end | start_line (omitted for a single-line finding) |
| (always) | side: "RIGHT" |
body | body (producer text as written) |
head_sha | commit_id |
event is COMMENT. Maidan delivers findings; it does not approve or
request-changes on the producer's behalf. The worker posts the review after a
successful GitHub summary comment. A missing head_sha, no
usable findings, a non-reviewed status, Slack-only deliver_to, a vanished
envelope at send time, or a GitHub 404/422 skips the review and still
delivers the summary (maidan_github_review_total{skipped}). A GitHub 5xx,
rate-limited 403, or revoked-token 401/403 also leaves the summary
delivered ({failed}) — failing the outbox after the comment has posted would
duplicate it on retry. Operator replay PATCHes the summary and POSTs another
COMMENT review on the current envelope head_sha (the recovery path for a
transient review failure). Review errors never disable a projector
issue-link. Dual-surface envelopes (GitHub + Slack) post both summaries; only
GitHub creates a review. Projector rows aimed at the same issue never call
create_review.
Each result POSTs a new COMMENT review (a re-review on a new head_sha lands
on that commit); the 379 summary comment is the object that updates in place.
The deliver_to grammar (pinned)
An array of target objects. Each names a surface plus per-surface detail:
[
{ "surface": "github", "repo": "example/repo", "pr": 3915 },
{ "surface": "slack", "channel": "C0123ABCDEF" },
{ "surface": "slack", "channel": "C0123ABCDEF", "thread_ts": "1759500000.000100" },
{ "surface": "github_branch", "repo": "example/repo", "branch": "feature/agent-fix-1a2b", "base": "dev" },
{ "surface": "linear", "team": "6a1f1c4e-2f0b-4d5e-9a7b-0c1d2e3f4a5b", "issue": "ENG-123", "state": "In Review" }
]
Rules
surfaceis a lowercase identifier. Known values today:github,slack,github_branch,linear.- An unknown
surfaceis skipped with a recorded warning, never an error. Partial delivery is the model: one target failing or being unroutable must not sink the others. githubrequiresrepo(owner/name) andpr(an integer issue or PR number). Delivery is a PR/issue comment.slackrequireschannel, and it MUST be a channel ID (C…/G…), not a#name. Names are mutable and ambiguous, and an allowlist keyed on a mutable name is not an allowlist. An optionalthread_ts(a Slack message timestamp,1759500000.000100) posts the delivery as a reply in that thread; the allowlist still blesses the channel. Athread_tsthat is not a Slack timestamp makes the target unusable (a recorded skip), rather than posting top-level.github_branchrequiresrepo(owner/name),branchandbase. It commits api.change.result/1tobranchand opens a draft pull request againstbase. It is logged asgithub_branch:<repo>@<branch>.branchmust matchfeature/agent-[a-z0-9][a-z0-9-]*;basemust be blessed for the repository, and is neverprod.linearrequiresteam(the team's id, a UUID) andissue(its identifier,ENG-123, or its id). Delivery is a comment on the issue. An optionalstatenames a workflow state of that team, by its exact name, to move the issue to once the comment lands. Ateamthat is not an id, or a blankstate, makes the target unusable (a recorded skip). See Linear.- An empty or absent list delivers nowhere. This is a valid, supported outcome — not a misconfiguration.
- New surfaces add new object shapes. Rule 2 makes that backward-compatible: an older Maidan skips a surface it does not know.
Where the producer gets it
The task author sets the routing intent in the seeding message's metadata under
deliver_to; the producer carries it through opaquely and echoes it into the
result envelope. Maidan reads it from the result envelope, not from message
metadata.
The trust model — deliver_to selects, the workspace allowlist authorizes
This is the part a producer must understand, because it changes what "success" means.
A result is written by an agent. Any caller holding thread:transition on the
thread can write any deliver_to. Maidan's connector credentials, by contrast,
are operator-held and can reach many repositories and channels. Routing straight
off an agent-supplied list would make Maidan a confused deputy: a compromised or
merely buggy agent could post under Maidan's identity anywhere that credential
reaches.
So delivery is authorized twice:
deliver_toselects which of the permitted targets this particular result goes to. The producer stays in control of routing and no surface is hardcoded.- A per-workspace egress allowlist authorizes. An operator blesses
github:example/repoorslack:C0123ABCDEFonce, overPOST /workspaces/:wid/egress-targets(token:admin;GETlists,DELETE …/:tidrevokes). A target absent from the allowlist is skipped with a recorded warning — the same treatment as an unknown surface.- A selector must be an id, never a name: a Slack channel id (
C…/G…), or a GitHub repositoryowner/name. A#channel-nameis refused, because the channel a name points at can change under the blessing. - On GitHub the blessing is the repository, not the issue — one blessing covers every PR in it, so an operator does not file a ticket per review.
- A selector must be an id, never a name: a Slack channel id (
- The allowlist defaults to empty, following the same fail-safe as Maidan's secret-egress broker: with nothing configured, nothing is trusted and nothing is delivered.
Operator setup — blessing a target
One token:admin call per destination, per workspace:
curl -sS -X POST "$MAIDAN/workspaces/$WORKSPACE_ID/egress-targets" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' \
-d '{"surface":"github","selector":"example/repo"}'
curl -sS -X POST "$MAIDAN/workspaces/$WORKSPACE_ID/egress-targets" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' \
-d '{"surface":"slack","selector":"C0123ABCDEF"}'
For the change flow, bless each repository and its base under
github_branch, as owner/name@base (its own rows; a github comment
blessing does not allow commits), and the Slack channel the replies go to. A full seed, as the audited calls, is in
Production.
GET the same path lists what is blessed; DELETE …/egress-targets/:tid revokes.
Blessing is idempotent — a re-bless returns the existing entry with its original
created_at, so the list stays "what may we post to" with nothing to reconcile.
The selector is two fields, not one string. There is no github:owner/repo
target syntax at this boundary: surface is the enum (github | slack |
github_branch) and
selector is the per-surface id (linear is a surface too). The rules, enforced on every write path
(400 with the reason on a violation):
| Surface | selector | Rejected |
|---|---|---|
github | a repository, owner/name | anything containing # — the blessing is the repo, not the issue, so one call covers every PR in it |
github_branch | a repository and the one base change pull requests may target, owner/name@base (one row per base) | a selector without a base, a prod base, an agent branch as a base. A separate surface with its own rows: a repository blessed for github comments is not writable by the change flow |
slack | a channel id, C… or G… | #channel-name — a name is mutable, and the channel a name points at can change under the blessing |
linear | a team id, a UUID | a team key (ENG) or name — a key can be renamed, and another team can take the old one |
Leading/trailing whitespace is refused on both. The authorization key is coarser
than the delivery target on GitHub: a result aimed at example/repo#42 is
authorized by the example/repo blessing.
What this means for the producer
A perfectly correct deliver_to can still deliver nowhere. That is not a
producer bug and must not be reported as a failure. Treat "delivered nowhere" as
a normal outcome; the per-target disposition is readable from the delivery-status
API below, and an operator — not the agent — fixes an unblessed target.
The change flow (pi.change.result/1 → github_branch)
A coding seat (the producer's coding agent) edits a checkout but holds no GitHub write credential. It returns the commit it started from and a diff; Maidan, holding the operator's token, makes the commit and opens a draft pull request, and answers in the Slack thread the work started in.
The envelope. result_kind is pi.change.result/1. Besides the usual
envelope fields, Maidan reads:
| Field | How Maidan uses it |
|---|---|
status | changed, no_change, seat_error, content_blocked (the producer found a secret in the diff, title or summary, and sent none of it) or rebase_conflict (a squash-rebase round whose rebase stopped on conflicts in the producer's checkout). Only changed writes to GitHub. Any other status records the github_branch target as skipped, with the status in the reason, makes no GitHub call, and the Slack reply says the status; for rebase_conflict it says the rebase stopped on conflicts and gives error_detail, where the producer lists the conflicting paths. |
rebase | Optional. true (a JSON boolean; anything else is a normal round) makes this a squash-rebase round: base_sha is the current head of the target's base branch and diff is the whole branch's change against it. See below. |
base_sha | The full SHA (40 or 64 hex) the diff applies to, captured before any edit. The commit's parent. Never taken from thread metadata or the branch. |
branch | The branch the producer worked on, echoed verbatim. Must equal the target's branch, or the delivery is refused. |
diff | A git diff that applies with git apply to base_sha. |
error_detail | Optional. Why a run that changed nothing ended. The Slack reply to a status other than changed gives it after the status, in place of summary, with a cause repeated across retries said once and the whole cut at 600 characters. Without it the reply gives summary. |
title, summary | Optional. The commit subject and the draft pull request's title, and the commit and pull request body; mentions in summary are defused. Without title, Maidan uses the thread's opening message (the !change instructions), first line, cut at a word to 72 characters (the branch name if there is none). Without summary, the body names the Maidan thread and the commit SHA. Missing text never stops a delivery. |
pr_state | Optional, {title, body}. body describes the whole branch against its base as it stands after this round (where title and summary describe this round). Maidan keeps it in one section of the pull request body, between <!-- pi:current-state --> and <!-- /pi:current-state -->, replacing only that section on each delivery and appending it when the markers are missing, so edits people and review bots make elsewhere in the body stay. Mentions are defused and the section is capped at 20,000 characters. pr_state.title is not read: a title people fixed is never reverted. A failed body edit never fails the delivery. |
What a changed result does, per github_branch target the workspace has
blessed:
- Checks the hard-coded rules, at routing and again before any write: the
branch matches
feature/agent-[a-z0-9][a-z0-9-]*and is notprod,main,master,stagingordev; the base is notprodand not the branch;owner/name@baseis on the workspace allowlist; and no pull request for the branch is open into another base. Each refusal is recorded with its reason and nothing is written. See Production for the full list of what Maidan never does with the token. - Reads the branch. When it is missing, it is created at
base_sha— after the diff has been applied in memory, so a diff that does not apply leaves no branch behind. - Refuses when the branch head is not
base_sha, or when the result'sbranchis not the target'sbranch. - Fetches only the files the diff touches, at
base_sha(GET /repos/{repo}/contents/{path}?ref={base_sha}), applies the hunks, and refuses when one does not match. Supported: modified, added, deleted and renamed text files (a rename may arrive as a delete plus an add, which is how some producers send it, or as a git rename),100644/100755modes,\ No newline at end of file, hunks that moved. Refused: binary patches, copies, symlinks, submodules, quoted paths, paths outside the repository or under.git. - Commits through the Git Data API: one blob per changed file, a tree on the
base tree, a commit whose only parent is
base_sha, and a fast-forward of the branch (force: false). A squash-rebase round is the one exception; see below. - Opens a draft pull request against
basewith the title and summary when none is open for the branch. Maidan posts no other comment, requests no review, and never marks the pull request ready, approves or merges it. - Replies in the Slack target's thread with the commit SHA and the pull request link. The reply waits for the branch delivery; a target that was refused or skipped is reported with its reason.
Refusals are recorded, not retried. A refused target is skipped with the
reason in last_error on the delivery-status API (GET /threads/:id/deliveries). A GitHub 5xx or rate limit retries with backoff
like any delivery; a 401/403/404 dead-letters the target.
Idempotent per thread and result. The commit message carries a trailer
Maidan-Change: <thread_id>/<digest>, the digest covering base_sha and the
diff (and, for a squash-rebase round, the rebase flag, so a rebase never
takes a normal round's commit for its own). A retried or replayed delivery that finds the branch head is that commit,
on top of base_sha, reuses it; the pull request is looked up before one is
opened. A retry makes neither a second commit nor a second pull request.
A follow-up is a new result whose base_sha is the branch head the last
change left: it commits on top and finds the pull request already open.
A squash-rebase round ("rebase": true) is a follow-up that rebases onto
the current base instead of merging the base in, for a queue that takes only
linear pull requests. branch is the existing agent branch, base_sha the
base branch's current head, and diff the whole branch against it. On top of
every rule above (the branch shape, the allowlisted base, never prod, the
open pull request's base, and the merge-queue refusal below), Maidan refuses
it, before any write and with the reason recorded, when:
- no pull request is open for the branch;
- someone else pushed: the branch head is not the newest round the change flow recorded for that pull request in this workspace (a commit another workspace delivered counts as someone else's). The reason names both SHAs;
- the base is not current:
base_shais not the base branch's head right now, so a rebase never moves a branch backwards; - the diff does not apply to
base_sha.
Otherwise it applies the diff to the base_sha tree, makes one commit whose
only parent is base_sha with the usual Maidan-Change trailer, re-reads the
branch head immediately before the move, and moves the branch with
force: true only if the head is still the one it checked. This is the change
flow's only non-fast-forward write. It is allowed only for a
feature/agent-* branch whose head is the last commit Maidan delivered, and
only through one guard in change_flow.rs that no other code can call: the
GitHub client's force method takes a single-use pass that only that guard
mints, bound to the branch and both commits, and checks the branch shape
again. GitHub's ref API has no compare-and-swap, so a push landing between the
re-read and the move (one round trip) is the window that remains. The round is
recorded with rebase: true and base_behind 0, and the pull request's
current-state section is updated from pr_state as for any round. A retry that
finds the head is already that rebase commit (same trailer, parent base_sha)
reuses it and writes nothing.
A rebase conflict is the producer's to resolve: it sends status: "rebase_conflict", Maidan records the target as skipped with no GitHub call,
and the Slack reply says the rebase stopped on conflicts and gives
error_detail (the conflicting paths, when the producer lists them).
The merge queue. For a repository listed in MAIDAN_MERGE_QUEUE_LABELS,
the mark-ready app may enter a ready agent pull request into the merge queue
by asking Maidan to add the queue's label (POST /operator/github/enqueue
with repo and pull_number), read whether it is labelled (GET), and take
the label off (DELETE). Enqueue reads the pull request once and refuses
unless every check holds on that read: a label is configured for the
repository; the pull request is open, not a draft and not from a fork; its
head is a feature/agent-* branch into the pinned base and on the workspace
allowlist; and its head is the commit the change flow last delivered to it.
The last check keeps a commit pushed after the review out of the queue: the
refusal names both SHAs. Dequeue skips that comparison, since the queue may
have rebased the head, and a pull request already without the label is
answered "was not labelled". Both are recorded in the audit log as
github.enqueue.
While a pull request carries its repository's queue label, the change flow refuses a delivery to its branch before any write: the queue merges the head it took, and a later commit would land apart from what was reviewed. Dequeue first, then deliver the follow-up.
Credentials. The commit and the pull request are made with
MAIDAN_GITHUB_TOKEN (see Production),
so they are authored by that token's owner. The token needs contents:write
and pull_requests:write on each repository. With a GitHub App configured
(MAIDAN_GITHUB_APP_ID, MAIDAN_GITHUB_APP_INSTALLATION_ID and
MAIDAN_GITHUB_APP_PRIVATE_KEY), they are made with the app's installation
token instead and authored by the app's bot account.
Per-surface behaviour
GitHub
- Body is
renderedverbatim. GitHub renders GFM, so the producer's markdown arrives as written. - A
view_urlbacklink is appended. - Truncated with an explicit marker if it would exceed GitHub's comment ceiling
(65536 characters). Do not assume
renderedarrives whole — put the load-bearing content early and rely on the backlink for the rest.
Slack
- Slack does not render GFM. Slack speaks mrkdwn:
*bold*not**bold**,<url|text>not[text](url), no headings, no tables. Postingrenderedverbatim would ship visibly broken output. - So Slack receives
summary, a compact Maidan-projected digest, and theview_urllink — notrenderedas-is. A producer that wants precise Slack formatting should makesummarycarry the weight.
Linear
- The comment body is the same Markdown a GitHub comment gets (
rendered, the PR line, attachments and theview_urlbacklink), cut at 65536 characters. A change result instead gets the change reply a Slack thread gets: the commit and draft pull request once thegithub_branchdelivery lands, or the status and the producer's reason when nothing was committed. The comment waits for the branch delivery the same way. - The issue must be in the team the result names. The allowlist blessed the team, not the issue, so the sender looks the issue up first and refuses it (a recorded skip, no write) when it is in another team.
- A later result for the thread edits the same comment. A comment deleted in Linear since is posted again.
statemoves the issue after the comment lands, and every delivery of the result moves it again. A state the team does not have, or a move Linear refuses, is logged; the comment stands and the delivery isdelivered.- The operator sets
MAIDAN_LINEAR_API_KEYand blesses each team by id:{"surface":"linear","selector":"<team id>"}.
All surfaces
- Mention sequences are neutralized at the egress boundary.
renderedis producer-authored but quotes attacker-influenced code; an@org/teamin a GitHub comment or a<!channel>in Slack would ping real people from attacker-controlled bytes. Maidan defuses these. Producers do not need to.
Idempotency
A thread holds exactly one result (set_thread_result upserts). Re-running a
review over the same thread therefore updates the existing delivery in place
rather than stacking a second comment.
Maidan keys a delivery on (thread_id, target) and remembers the external
reference it created — a GitHub comment_id, a Slack ts. A later result on the
same thread edits that object. A hidden marker at the start of the GitHub comment
body is the recovery path if the stored reference is ever lost.
Producer obligation: re-review the same thread. A new thread per run is a new delivery, and will produce a second comment — correctly, since it is a different unit of work.
Failure semantics
- Partial delivery is normal. A connector failure on one target never prevents the others.
- Nothing is silently dropped. A failed delivery retries with backoff and dead-letters after a bounded number of attempts, where an operator can inspect and replay it. A misconfiguration-class error (401/403/404) on projector traffic disables the link rather than retrying forever. The same status on a result delivery dead-letters that target without disabling the projector link — a result must not take down room-to-issue relay.
- A non-
reviewedstatus is surfaced as a failure, usingstatusalone. It is never rendered as a passing review and never quietly skipped. - Every delivery attempt is audited.
Discoverability
result_kind is a namespaced string, not a closed enumeration. A producer publishes
result_kind = "example.review.result/1" inside schema = "maidan.waiter.result/1"; a
future producer ships a new string and the facet works without a server change.
The old guess of a closed enum (decision|plan|merge_authorized) is not the
wire vocabulary and is not a filter value.
List (not message-FTS) surfaces, workspace:read:
- REST:
GET /workspaces/{id}/results?result_kind=example.review.result/1(limitoptional, default 50, clamp 1–500). Omitresult_kindto list every non-tombstoned result the caller can access. - MCP:
list_thread_resultswith the same optionalresult_kind/limit. Workspace comes from the token; there is noworkspace_idargument.
Match is exact on the indexed string. example.review.result and
example.review.result/10 do not hit example.review.result/1. Private-channel rows the
caller cannot access are omitted. This is not GET /workspaces/{id}/search
and not the ADR JSON convention "kind": "decision" in
Integration.md.
Close-gate
A reviewed example.review.result/1 whose findings contain any
severity == "critical" is a request_changes from a
review-skilled producer (REVIEW_SKILL = "review"). If the thread has no
requirement, Maidan arms k = 1 so the existing close-gate refuses
closed until a human who is neither owner nor assignee approves.
This is an adapter, not a new gate:
- Warning-only / wrong
result_kind/ notreviewed/ unskilled producer → no-op. - An existing
kis left alone. - A clean re-review does not auto-approve.
- Empty
deliver_tostill arms — the room blocks the land even when nothing is posted externally. - The GitHub review posted stays
event: COMMENT. The room gate is the land decision; the PR is not REQUEST_CHANGES.
PUT /threads/:id/result and MCP set_thread_result arm on the write
path. The ThreadResultSet bus consumer arms again (every-replica /
replay). Both are idempotent.
Delivery status
Per-thread delivery state is readable over REST and MCP, with a replay action, so a producer can confirm where its result actually landed without scraping the external surface.
| Call | Surface | Capability |
|---|---|---|
GET /threads/:id/deliveries | REST | workspace:read + thread access |
POST /threads/:id/deliveries/:did/replay | REST | workspace:write + thread access |
list_result_deliveries {thread_id} | MCP | workspace:read + thread access |
replay_result_delivery {thread_id, delivery_id} | MCP | workspace:write + thread access |
One row per (thread, target):
{
"id": "…", "thread_id": "…",
"surface": "github", "selector": "example/repo#42",
"status": "delivered",
"external_ref": "998877",
"armed_revision": "2026-09-15T10:00:00Z",
"delivered_revision": "2026-09-15T10:00:02Z",
"attempts": 1, "last_error": null,
"created_at": "…", "updated_at": "…"
}
statusispending(armed, not yet sent),delivered,failed(the transport gave up and the egress queue dead-lettered it), orskipped(deliberately not delivered — an unknown surface, or a target the workspace has not blessed).skippedis not an error;last_errorcarries the reason.selectoris the full destination (owner/name#123on GitHub), which is finer than theowner/namethe allowlist is keyed on.external_refis the object we created — a Slackts, a GitHub comment id. It is what the next revision edits in place.armed_revision/delivered_revisionareThreadResult.produced_atwatermarks: newest seen vs newest actually landed.delivered_revision: nullmeans never delivered. A result is re-delivered only when itsproduced_atis newer thanarmed_revision— that is the dedup, and it is why a replayed event is a no-op while a genuine re-review is an update.- An empty list is
200 []— the result was routed nowhere, which is valid.
Replay re-enqueues one row and re-checks the allowlist: an unblessed target
stays skipped (status is not policy). It does not bump armed_revision.
What Maidan will never deliver
- Raw agent output of any kind — seat stdout, un-rendered model text, tool
transcripts. Only
rendered,summary, and structuredfindings. - Anything at all when
statusis notreviewed(beyond the failure notice). - Anything to a target the workspace has not blessed.
Maidan does not run seats, does not render reviews, and does not become a
CI product. The close-gate adapter reads findings[].severity
adapter only (critical → request_changes); it does not
judge finding bodies or invent a land vocabulary. GitHub review event
stays COMMENT. It delivers trusted bytes to blessed
surfaces, durably, once.
Open requests to result producers
All three are now carried.
-
Carried and used. Present onhead_sha— the commit the review was computed against.maidan.waiter.result/1(fixture lock). Maidan passes it as GitHub'scommit_idrather than resolving the PR head at delivery time. -
The frame of reference forPinned (380.1). File-absolute post-image lines atline_range.head_sha, 1-indexed inclusive, GitHub RIGHT. See Inline findings. -
CallCarried, and the wall half needs nothing. Maidan ships a per-task token/USD/turn/wall budget envelope that stops a run when it is exceeded, and a producer that reports spend only inside an opaque result leaves that envelope blind. Self-reporting through the ledger API is the supported path — Maidan deliberately does not fold an agent-declared cost out of a result payload into its own billing basis. The exact contract:report_usagewith the run's cost and wall time.- Cost, tiered tokens, and turns are reported.
report_usagecarries a uniqueusage_report_id, the activeclaim_lease_id, model, explicit input/output/cache-read/cache-write tokens, the matching immutable price snapshot, integerusd_micros, and turns — over MCP orPOST /threads/:id/usage. Exact retries are idempotent; conflicting reuse and stale leases fail before charging. The reporter and payer are derived, never caller-selected. Money never crosses the wire as a float, and there is nocost_usdargument — convert at the edge. - Wall time is not reported — it is derived. There is no
duration_secs/wall_secsargument and there will not be one: a self-reported clock is the same trust hole as a self-reported cost, and Maidan already holds the authoritative one.max_wall_secsis measured against the thread's working clock (work_started_at), so elapsed time is the room's own measurement. A claim whose lease lapses is charged the time it worked (acknowledgement to deadline) when the reaper frees it, so the bound also stops a hung agent that never reports. A release, unassign, reassignment, freeze, SCIM deactivation, budget stop or close charges from the acknowledgement to that moment (Integration, step 4). - To arm the wall dimension, acknowledge the claim.
work_started_atisNULLuntil the holder callsacknowledge_claim {thread_id, claim_lease_id}(RESTPOST /threads/:id/claim/acknowledge). The holder comes from the authenticated identity; it is not a caller-selected field. Until thenmax_wall_secsis inert — a claimed-but-unacknowledged run is never stopped on time. Acknowledging is idempotent (the first start time is kept) and is also what splitsclaimedfromworkinginGET /channels/:cid/occupancy. - Extra arguments are rejected. Lifecycle and usage request schemas use strict deserialization, so stale acting-identity fields fail at the boundary instead of being silently ignored.
- Cost, tiered tokens, and turns are reported.
Versioning
The grammar above is frozen at maidan.waiter.result/1 (confirmed against the shipped delivery path).
Additive fields are free. A change to the meaning of an existing field, or to the
deliver_to shape, requires a new schema value — Maidan will route on the
discriminator and an unrecognized one is inert rather than mis-delivered. The
Maidan-side lock is crates/maidan-types/tests/fixtures/waiter_result_v1.json:
a producer-side grammar change breaks that test.