Outbound Event Stream ([events])
Gitmoot can push a small, versioned, redacted JSON event to one HTTP endpoint
whenever a job reaches a terminal state, pauses awaiting a human, or an
organization role remains in a configured alert condition. This is an
off-by-default, best-effort outbound seam (#446): with no [events] config
and no organization event rules, nothing is constructed and behavior is
byte-identical to a build without it.
It complements — it does not replace — the existing observability surfaces
(gitmoot job watch, the local dashboard, PR comments). The typed lifecycle
events recorded in SQLite (job_events) are unchanged; this is an additional
fan-out, not a migration.
What it emits
The pilot emits a tight allowlist of event types over the webhook transport:
event_type | When |
|---|---|
job.finished | a job reaches the succeeded terminal state |
job.failed | a job reaches the failed terminal state |
job.blocked | a job reaches the blocked terminal state |
job.needs_attention | a tree pauses awaiting a human (the escalate_human pause today) |
job.deferred | the daemon re-queued a run whose delivery failed on a retryable operational blocker (runtime auth, rate limit/quota, network/GitHub outage, checkout contention) — it will be re-dispatched automatically. Since #532 slice E this is a first-class transition emitted INSTEAD of job.failed (no preceding job.failed for that run) |
org.input_pending | a Herdr pane bound to an organization role continuously reports an interactive input dialog past the configured threshold |
org.reply | the daemon drains a durable, coalesced batch of notes addressed to one organization role |
Each terminal transition emits exactly once. org.input_pending is an
episode alert instead: it repeats at most once per configured interval while
the same interactive dialog condition remains observed. The engine owns the
succeeded/failed/blocked emit on its Mailbox terminal path; the daemon owns the
pre-flight (queued -> failed|blocked) and permission-blocked cases that never
pass through that path. job.needs_attention is emitted once per fresh
escalation round (a re-advance does not re-emit).
job.deferred is a first-class transition — a deferred run does NOT emit
job.failed first. When a run's delivery fails on a classified operational
blocker (runtime auth rejected, provider rate limit/quota, network/GitHub outage,
or a self-healing checkout contention), the classification happens
pre-terminally in the mailbox seam (#532 slice E): the run is re-queued
directly and the daemon emits only job.deferred for that job_id (detail
carries the blocker class, the attempt N/M retry budget, and the earliest
retry at timestamp), then silently re-dispatches after the hold. Product
failures (the agent answered with a gitmoot_result, including
decision=failed) still emit job.failed and are never auto-retried.
Migration note: before slice E the daemon emitted job.failed then
job.deferred for the same run (the accepted first-slice flap). That flap is
gone. The safe consumer rule is unchanged and forward-compatible: treat
job.failed as final only when it is not immediately followed by a job.deferred
for the same job_id.
The following event_type values are reserved for the graduate step. They
are enumerated in the contract so a consumer can switch over them
forward-compatibly without a schema bump when they start arriving, but the pilot
does not emit them: job.started, delegation.escalation,
delegation.finalized, orchestration.finished.
The event contract (schema_version = 1)
Every event is a single JSON object:
{
"schema_version": 1,
"event_type": "job.needs_attention",
"job_id": "implement-task-7",
"root_id": "coordinator-job-id",
"repo": "owner/repo",
"status": "awaiting_human",
"ts": "2026-06-16T12:00:00Z",
"detail": "Choose the rollout window",
"cause": "escalation"
}
| Field | Type | Notes |
|---|---|---|
schema_version | int | Contract version. Currently 1. A breaking field change bumps it. |
event_type | string | The enum above. |
job_id | string | Opaque job id this event is about. |
root_id | string | The coordination tree's root id, so a consumer can aggregate a run client-side. |
repo | string | owner/repo only — never an absolute checkout path. |
status | string | Terminal/lifecycle state (succeeded/failed/blocked/awaiting_human/input_pending/attempted). |
ts | string | RFC3339 emit time. |
detail | string | Short redacted human-facing string (failure summary, the escalation question). |
cause | string | Optional internal discriminator (escalation, ask_gate, merge_guard, permission_guard, blocked_since, input_pending_since, or addressed_note). |
cause is an additive optional field, so schema_version remains 1 and
existing events serialize unchanged. It is a trusted enum assigned at emit
sites, not free text, and therefore is not passed through path scrubbing or
redaction.
There is no synthetic orchestration.finished in the pilot — every event
carries root_id, so a consumer groups a run client-side. Server-side
tree-convergence aggregation is a documented graduate item.
Redaction
Every outbound free-text string field is redacted at event construction through the same
redactor Gitmoot uses for off-box PR/issue comments and bug reports
(workflow.RedactCommentText): GitHub tokens, OpenAI keys, AWS secrets, and
api_key/token/secret/password assignments are replaced with
[REDACTED]. The detail field is then additionally scrubbed of absolute
filesystem paths — host home layout, checkout and worktree paths embedded in
pre-flight failure detail (e.g. git worktree add /root/.gitmoot/...) collapse
to <path> — so host layout and usernames never leak. repo is reduced to
owner/repo only. No absolute paths, raw runtime output, or secrets leave the
box.
Best-effort delivery
Delivery is fire-and-forget. A slow, hung, erroring, or full consumer never
blocks or fails a job — this mirrors the escalate_human notifier contract:
- Each event is handed to a small buffered channel drained by one background goroutine, so concurrent emits from many workers serialize cleanly.
- Each POST is bounded by
[events].timeout(default2s). A hung consumer times out and the event is dropped. - On a full buffer, a transport error, or a non-2xx response the event is
dropped and a single best-effort
event_sink_dropjob event is recorded locally (so a drop is observable without coupling delivery to the job).
Webhook delivery has no outbox or retry. Reply, blocked, and escalation role wakes are the exception: their producer-agnostic SQL outbox is described below.
Organization event-rule wakes
The daemon can also use the same event seam to wake a Herdr pane bound to an
organization role. This is independently off by default: zero event_rules
rows means no evaluator and no Herdr call, whether or not the webhook is on.
[org.roles."maintainer"]
parent = "owner"
scope = ["owner/*"]
pane = "w1:p2"
gitmoot org events rule add --on guard --match owner/repo --wake maintainer
gitmoot org events rule add --on review-verdict --match owner/repo --wake maintainer
gitmoot org events rule add --on blocked --repo tendwire --wake maintainer
gitmoot org events rule add --on pane_input_pending --wake maintainer
gitmoot org events rule add --on reply --wake maintainer
gitmoot org events rule add --on fact --wake maintainer
gitmoot org events rule add --on reply --wake operator --scope observer
gitmoot org events rule list
gitmoot org events rule set-scope <rule-id> observer
gitmoot org events rule rm <rule-id>
Rules default to --scope addressed: when an event carries a target role, only
the matching addressed rule receives it. During rule evaluation,
--scope observer exempts a rule from that addressee gate.
Events without a target role match observer rules only.
Durable-outbox claim authorization is scope-blind: among enabled,
filter-matching rules for the event's own kind, wake-role equality with the
addressed target is the only routing condition. An observer-scoped rule is
therefore delivered when its wake role equals the target; when it differs and
no other target-role rule authorizes the batch, the batch remains pending.
set-scope changes an existing rule between addressed and observer.
org.fact is the exact-targeted delivery event for a durable awaited fact. A
fact rule wakes only the role named by the outbox row. Satisfaction and bounded
expiry both use this event family; delivery creates no acknowledgment or
completion obligation. Expired subscriptions remain queryable rather than being
deleted or silently omitted.
Upgrades preserve the existing global view by promoting non-reply rules with an
empty match filter to observer; reply rules remain addressed because reply
already carries a target role. gitmoot doctor warns when an event kind with a
production target-role writer has no enabled observer rule, including when the
rule set is empty.
Filtered non-reply rules remain addressed after upgrade and must be promoted
manually with set-scope when observer delivery is intended.
Kinds are escalation, attention, guard, job-terminal,
review-verdict, blocked, recycle-overdue, pane_input_pending, reply,
directive, and fact.
Repository comparison for a slash-bearing owner/repo filter is
case-insensitive and exact. Job IDs always use case-insensitive substring
matching, including slash-bearing delegation IDs. Without a slash, repositories
also use substring matching; empty matches all. Pass only one of --match and
--repo. A successful review terminal whose decision is approved or
changes_requested matches both job-terminal and review-verdict. Addressed
rules may deliver that verdict to both the requesting org role and the pull
request's implementing role. When a successful ownership lookup finds no
implement job or branch lock attribution, the review's persisted lead
identifies a persistent implementing seat; lookup errors remain fail-closed. If
neither role can be resolved, addressed rules fail closed while observer rules
can still receive the verdict. A plain
job.blocked event matches both job-terminal and blocked, while
guard-caused blocks match guard first. A synthesized blocked_since event
matches only blocked. Task episodes due in
one evaluator pass are emitted as one oldest-first digest; blocked roles retain
one event per role. Set
[orchestrate].blocked_role_wake_after to a positive Go duration to emit such an
event when a task or Herdr role stays blocked past the threshold, re-nudging at
most once per that interval while it remains blocked (so a dropped wake
self-heals on the next interval instead of being lost). The same threshold and
repeat interval apply to org.input_pending: Herdr's input_pending: true
overrides the pane's last idle or working status, opens a separately keyed
episode, and matches only pane_input_pending. 0s (the default) disables
these evaluators. An episode is cleared on a definitive non-matching
observation, or once the subject stops matching for a short grace
(so a role gone for good is not leaked); a brief unknown/absent snapshot blip
within that grace never resets it, and a later re-block starts a fresh episode.
Each synthesized event's detail carries the stable since-time, so a re-nudge
(same job_id + same since) is distinguishable from a fresh episode.
All classified system events persist an immutable inbox notice and its outbox
obligation together before the producer returns. Addressed recipients do not
depend on optional rules; enabled observer rules can add recipients. A disabled
addressed rule explicitly mutes delivery, while an observer mute cannot suppress
an addressed obligation. Review notices retain repo, PR, exact head and canonical
purpose, including post-merge scope. Awaited-fact notices suppress duplicate
legacy review wakes for the same recipient.
gitmoot message escalate creates a tracked request in the shared inbox.
Resolving it records an answer and receipt in its thread and notifies the actual
requester. Historical requests without an identifiable requester use system
provenance, not an invented target. Historical import creates no new wakes and
preserves uncertain delivery evidence. Working recipients batch notifications
for 120 seconds from the oldest compatible queued item. Idle recipients have no
batching delay; becoming idle releases a held batch on the next five-second
drain tick. New arrivals do not restart the deadline. Review-result notifications
use the same policy; org.wake_coalesce_hold is no longer supported.
Compatible notifications share one wake, bounded at ten rows per wake.
Different event kinds, directives and awaited facts retain their grouping
boundaries. Collapsed rows carry an explicit coalesced_into relationship.
Inbox, CLI and dashboard status follows the survivor's actual state, not an
unconditional resolved status. Missing, invalid or cyclic historical links
remain uncertain. Interrupt accounting still counts physical wakes only.
Activity is checked every five seconds, independent of repository sweeps. Offline, unknown, unbound or unsafe recipients remain pending; runtime admission still protects drafts, dialogs and operator input. Manual inbox reads are immediate. Claude/Codex tool-return hooks use the working deadline; prompt-submit and Stop hooks release mail at idle boundaries. Owner Grams retain their separate five-minute batching policy and immediate review-result handling.
Writable and read-only database handles initialize connection-local SQLite settings on every physical connection, including replacements after cancellation. Replies reserve the writer before reading their parent, so brief contention waits instead of failing a read-to-write upgrade. Known notification outcomes use bounded bookkeeping retries without resending transport. An uncertain message insertion is not automatically replayed.
The owner role has no pane or seat. The daemon's drain therefore delivers every
due wake addressed to owner (needs-attention escalations, blocked events,
notes, directives and review results) as one plain-text Herdr Gram
(herdr gram send --from gitmoot), with no event rule needed. Owner Grams are
at least two minutes apart; alerts due together share one Gram (up to ten
items). Rows are claimed before the send and each gets a receipt in
owner_gram_receipts with the gram id. An accepted Gram marks the row
delivered (accepted by Herdr, not proof the owner read it). A refused send
marks it failed with owner gram refused: ..., and gitmoot org wake retry
can resend it. An unknown outcome marks it delivery_unknown and is never
resent. gitmoot org status flags the owner row while any owner alert is
failed, delivery_unknown or stalled and not yet retried or superseded.
Each owner Gram is written for a phone: per item, a plain line naming who
needs the owner, the pull request and why, then What to do: and the pull
request link. Known gate reasons get a plain explanation. The unedited
original text follows below a separator and is never shortened; past Herdr's
8 KiB text limit it is attached as a file and the Gram says so. A merge-gate
miss only the repository's seat can fix (no implement work recorded for the
task) is escalated to that seat, not to the owner; other misses in the same
decision still go up the chart.
Pending, attempted, delivered, superseded, stalled, failed, and
delivery_unknown remain queryable per outbox row, and outstanding obligations
contribute to daemon tick health. A deliverable row still inside its hold is
reported as held rather than pending and is NOT an outstanding obligation:
it is waiting by design, so a hold longer than the tick interval does not make
every tick read as unhealthy.
A quiet burst tail is flushed by a later daemon tick; it does not require
another event. If the outbox or its delivery rules cannot be queried, or an
outbox row cannot be parsed or claimed, the drain is logged as unhealthy and
retried on a later tick without aborting unrelated repository work.
Before claiming a notice, the daemon checks the recipient's registered seat and
runtime. Unregistered, unseated, offline and ambiguous recipients keep their
pending notices and a visible deferral reason without spending attempts. A busy
recipient is not held back: the runtime decides. Only OMP seats (Herdr agent
kind omp) are notified by the daemon, through the Gitmoot OMP inbox add-on
(gitmoot plugin install omp). Claude Code and Codex seats stay pending with
waiting for recipient's next turn; any other runtime, or an OMP process
without a trusted add-on registration in <gitmoot home>/run/omp/, stays
pending with runtime notification capability unavailable. Nothing ever
types into a recipient's terminal.
The daemon matches the registration whose pid is a foreground process of the
seat's pane (herdr pane process-info), refuses symlinks, other owners and
group or other permissions on the directory, file and socket, and sends one
deliver request pinned to the registered runtime, session and generation.
accepted marks the row delivered: runtime admission, not reading or
completion. A deferred reply (for example stale_session or
operator_active), or any failure before the request is written, returns the
batch to pending without spending retry budget. Once the request is written,
a missing, late or unreadable reply leaves the row delivery_unknown, which is
retained without automatic resending.
An aged-out row whose delivery the store can PROVE is recorded as delivered
rather than delivery_unknown, with a wake_delivered job event on
wake-outbox:<id> carrying policy=resolved_by_destination_evidence. The proof
is DESTINATION EVIDENCE: a note in the directive's own workflow that
acknowledges it ([org:directive-ack id=<id> ...]) or records its delivery
([org:directive-delivered id=<id> ...]). Without that check the sweep recorded
a negative the store could disprove: a directive acknowledged two minutes after
its row was created was still flagged undelivered 78 minutes later, and every
instrument built on state and attempt_count then reported delivered work as
an outstanding obligation. The evidence must name THAT row's directive and live
in the directive's own workflow; a reply-class row has no equivalent marker, so
it keeps the unknown outcome rather than being laundered by an absent check.
A wake addressed to a role that CANNOT receive it, because no enabled rule
exists for that role and kind, records a wake_unroutable job event on
wake-outbox:<id> naming the role, the kind, the source and WHICH condition it
is: condition=route_removed when a durable tombstone shows the route was
deleted after the row existed, which is a retired seat and usually needs no
remedy, or condition=never_configured when no route history exists for that
role and kind at all, which is the case a route would fix. The record is written
at most once per row rather than once per tick, because the drain re-classifies
every tick and a per-tick append is what grows job_events without bound. The
row itself is NOT failed and NOT retried: a wake nobody can receive is not a
wake that is wrong, and a rule added later still delivers it. The distinction
rests on the deletion tombstones, so a role retired before that table existed
reads as never-configured.
delivery_unknown remains terminal and is still never retried: a row aged out
of attempted may in fact have been delivered before its outcome write was
lost, so re-emitting it would risk a duplicate interrupt to prove a negative.
The wake role's config sets pane = "<pane-id-or-label>": a wX:pY value must
name a currently live pane, while any other value is a unique pane label
resolved to the current id at wake time (so a recycled pane is still reached).
The same explicit binding drives live org presence; an unresolved binding
reports unknown presence, skips the wake with an observable log, and increments
the role's missed-wake counter. gitmoot org validate reports unresolved roles,
roles without enabled wake routes, and unclaimed labeled panes. Missing
bindings, unavailable Herdr and transport errors are swallowed after
lightweight logging; they never block or fail a job.
Each wake whose role binding cannot be resolved increments a durable,
consecutive counter for the wake role; a delivered notice resets that role's
counter. Transport errors, deferrals and other non-delivery outcomes do not
change it because a Herdr outage is infrastructure failure, not evidence that
every role ignored a wake. Set
[orchestrate].max_consecutive_missed_wakes to a positive integer to append a
⚠ flagged (N missed wakes) marker to that role in gitmoot org chart and
gitmoot org status; their JSON rows expose missed_wakes, flagged, and
flag_reason. The default 0 disables flagging while leaving the best-effort
counter available.
Configuration
Add an [events] section to the Gitmoot config file:
[events]
# The single endpoint each event is POSTed to as application/json.
# Empty (the default) means the webhook transport is OFF. Organization event
# rules, when explicitly added, can still consume the same local event seam.
webhook_url = "https://example.com/gitmoot-events"
# Per-POST timeout (Go duration). Default 2s. Bounds a hung consumer.
timeout = "2s"
# RESERVED for the graduate Unix-socket transport. Parsed and validated but
# UNUSED by the pilot (webhook-only).
# socket_path = "/run/gitmoot/events.sock"
| Key | Default | Meaning |
|---|---|---|
webhook_url | "" | HTTP(S) endpoint. Empty = OFF. |
timeout | 2s | Per-POST timeout (Go duration). Must be positive. |
socket_path | "" | Reserved for the graduate Unix-socket transport (unused today). |
webhook_url must be an http:// or https:// URL. An invalid timeout
duration or a non-http(s) URL is rejected at config load.
Graduate (go/no-go)
The pilot validates the contract and the best-effort guarantee with the least scaffolding. The following are out of scope and gated behind an explicit go/no-go on the tracking issue:
- The Unix-socket transport (
socket_path), behind the sameSinkseam. - More event types (
job.started,delegation.*,orchestration.finished). - A webhook outbox for at-least-once HTTP delivery (addressed-message role wakes already use a local durable outbox).
- A server-side synthetic
orchestration.finished(reliable tree convergence).