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.

WasIs now
pi.waiter.result/1maidan.waiter.result/1
pi.review.result/1example.review.result/1 (a result_kind)
view_in_piview_url

A schema/$type this build does not recognize makes parse_waiter_result return None, which is inert by design — no delivery is attempted, and because an unrecognized envelope is not an error there is no warning, no skipped delivery 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.json and 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.

PieceState on main today
set_thread_result / get_thread_result / ThreadResultSet eventShipped. A result is durable and observable.
Slack + GitHub connectors (post, link tables, link management)Shipped.
Durable retrying delivery queue + DLQ + replayShipped.
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_toShipped. Fetch → parse → per-target allowlist check then enqueue.
Idempotent update-in-placeShipped. Stored external_ref → update_*; GitHub recovery marker <!-- maidan:result:<thread_id> --> at byte 0.
Per-thread delivery status + replayShipped. GET /threads/:id/deliveries + POST …/deliveries/:did/replay + MCP list_result_deliveries / replay_result_delivery.
Inline per-finding PR review commentsShipped. 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 facetShipped. 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.

FieldRequiredHow Maidan uses it
schemayesEnvelope discriminator. Must be maidan.waiter.result/1. An unrecognized value means no delivery is attempted.
result_kindyesWhich producer shape this is, e.g. example.review.result/1. Recorded; the list facet shipped (see "Discoverability").
statusyesDelivery happens only on reviewed. Any other value delivers a short Maidan-authored failure notice instead — never silence, never a clean pass.
deliver_tonoThe routing list. Absent or empty is valid and normal: thread-only, delivered nowhere.
renderedon reviewedThe delivery body. Producer-authored trusted markdown.
summaryon reviewedOne line. The Slack body and the notification title.
view_urlnoA backlink appended to every delivery.
prnoA human back-reference echoed into the delivered body.
head_shafor inline commentsGitHub 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.
findingsnoTwo 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.

SurfaceCapability
PUT / GET / DELETE /threads/:id/lineagewrite/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_occupancythe 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[]:

EnvelopeGitHub
filepath
line_range.endline (last line of the range)
line_range.start when start != endstart_line (omitted for a single-line finding)
(always)side: "RIGHT"
bodybody (producer text as written)
head_shacommit_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

  1. surface is a lowercase identifier. Known values today: github, slack, github_branch, linear.
  2. An unknown surface is skipped with a recorded warning, never an error. Partial delivery is the model: one target failing or being unroutable must not sink the others.
  3. github requires repo (owner/name) and pr (an integer issue or PR number). Delivery is a PR/issue comment.
  4. slack requires channel, 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 optional thread_ts (a Slack message timestamp, 1759500000.000100) posts the delivery as a reply in that thread; the allowlist still blesses the channel. A thread_ts that is not a Slack timestamp makes the target unusable (a recorded skip), rather than posting top-level.
  5. github_branch requires repo (owner/name), branch and base. It commits a pi.change.result/1 to branch and opens a draft pull request against base. It is logged as github_branch:<repo>@<branch>. branch must match feature/agent-[a-z0-9][a-z0-9-]*; base must be blessed for the repository, and is never prod.
  6. linear requires team (the team's id, a UUID) and issue (its identifier, ENG-123, or its id). Delivery is a comment on the issue. An optional state names a workflow state of that team, by its exact name, to move the issue to once the comment lands. A team that is not an id, or a blank state, makes the target unusable (a recorded skip). See Linear.
  7. An empty or absent list delivers nowhere. This is a valid, supported outcome — not a misconfiguration.
  8. 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_to selects 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/repo or slack:C0123ABCDEF once, over POST /workspaces/:wid/egress-targets (token:admin; GET lists, DELETE …/:tid revokes). 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 repository owner/name. A #channel-name is 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.
  • 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):

SurfaceselectorRejected
githuba repository, owner/nameanything containing # — the blessing is the repo, not the issue, so one call covers every PR in it
github_brancha 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
slacka channel id, C… or G…#channel-name — a name is mutable, and the channel a name points at can change under the blessing
lineara team id, a UUIDa 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:

FieldHow Maidan uses it
statuschanged, 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.
rebaseOptional. 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_shaThe 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.
branchThe branch the producer worked on, echoed verbatim. Must equal the target's branch, or the delivery is refused.
diffA git diff that applies with git apply to base_sha.
error_detailOptional. 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, summaryOptional. 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_stateOptional, {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:

  1. 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 not prod, main, master, staging or dev; the base is not prod and not the branch; owner/name@base is 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.
  2. 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.
  3. Refuses when the branch head is not base_sha, or when the result's branch is not the target's branch.
  4. 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/100755 modes, \ No newline at end of file, hunks that moved. Refused: binary patches, copies, symlinks, submodules, quoted paths, paths outside the repository or under .git.
  5. 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.
  6. Opens a draft pull request against base with 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.
  7. 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_sha is 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 rendered verbatim. GitHub renders GFM, so the producer's markdown arrives as written.
  • A view_url backlink is appended.
  • Truncated with an explicit marker if it would exceed GitHub's comment ceiling (65536 characters). Do not assume rendered arrives 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. Posting rendered verbatim would ship visibly broken output.
  • So Slack receives summary, a compact Maidan-projected digest, and the view_url link — not rendered as-is. A producer that wants precise Slack formatting should make summary carry the weight.

Linear

  • The comment body is the same Markdown a GitHub comment gets (rendered, the PR line, attachments and the view_url backlink), cut at 65536 characters. A change result instead gets the change reply a Slack thread gets: the commit and draft pull request once the github_branch delivery 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.
  • state moves 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 is delivered.
  • The operator sets MAIDAN_LINEAR_API_KEY and blesses each team by id: {"surface":"linear","selector":"<team id>"}.

All surfaces

  • Mention sequences are neutralized at the egress boundary. rendered is producer-authored but quotes attacker-influenced code; an @org/team in 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-reviewed status is surfaced as a failure, using status alone. 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 (limit optional, default 50, clamp 1–500). Omit result_kind to list every non-tombstoned result the caller can access.
  • MCP: list_thread_results with the same optional result_kind / limit. Workspace comes from the token; there is no workspace_id argument.

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 / not reviewed / unskilled producer → no-op.
  • An existing k is left alone.
  • A clean re-review does not auto-approve.
  • Empty deliver_to still 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.

CallSurfaceCapability
GET /threads/:id/deliveriesRESTworkspace:read + thread access
POST /threads/:id/deliveries/:did/replayRESTworkspace:write + thread access
list_result_deliveries {thread_id}MCPworkspace:read + thread access
replay_result_delivery {thread_id, delivery_id}MCPworkspace: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": "…"
}
  • status is pending (armed, not yet sent), delivered, failed (the transport gave up and the egress queue dead-lettered it), or skipped (deliberately not delivered — an unknown surface, or a target the workspace has not blessed). skipped is not an error; last_error carries the reason.
  • selector is the full destination (owner/name#123 on GitHub), which is finer than the owner/name the allowlist is keyed on.
  • external_ref is the object we created — a Slack ts, a GitHub comment id. It is what the next revision edits in place.
  • armed_revision / delivered_revision are ThreadResult.produced_at watermarks: newest seen vs newest actually landed. delivered_revision: null means never delivered. A result is re-delivered only when its produced_at is newer than armed_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 structured findings.
  • Anything at all when status is not reviewed (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.

  1. head_sha — the commit the review was computed against. Carried and used. Present on maidan.waiter.result/1 (fixture lock). Maidan passes it as GitHub's commit_id rather than resolving the PR head at delivery time.

  2. The frame of reference for line_range. Pinned (380.1). File-absolute post-image lines at head_sha, 1-indexed inclusive, GitHub RIGHT. See Inline findings.

  3. Call report_usage with the run's cost and wall time. Carried, 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:

    • Cost, tiered tokens, and turns are reported. report_usage carries a unique usage_report_id, the active claim_lease_id, model, explicit input/output/cache-read/cache-write tokens, the matching immutable price snapshot, integer usd_micros, and turns — over MCP or POST /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 no cost_usd argument — convert at the edge.
    • Wall time is not reported — it is derived. There is no duration_secs/wall_secs argument 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_secs is 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_at is NULL until the holder calls acknowledge_claim {thread_id, claim_lease_id} (REST POST /threads/:id/claim/acknowledge). The holder comes from the authenticated identity; it is not a caller-selected field. Until then max_wall_secs is inert — a claimed-but-unacknowledged run is never stopped on time. Acknowledging is idempotent (the first start time is kept) and is also what splits claimed from working in GET /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.

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.