Skip to content

Orgabot is in private alpha. Request access, or join our Discord.

← All documentation
Reference

Workflows and the event spine

A mission executes a workflow rather than being one - stages, gates, environments and events as inspectable data, with policy deciding what no workflow may omit.

Orgabot used to run exactly one lifecycle. Resolve, plan, edit in a worktree, commit, verify, review, open a draft pull request, remediate, merge - every mission, on every project, in every organization. That fixed shape is why verification is evidence-based, why the reviewer is never the producer, and why pull requests open as drafts. It is also why a release objective could be marked complete with no tag and no release: the only terminal the pipeline knew was the pull request.

A mission now executes a workflow. Agents perform work; workflows control progression. The pipeline above is still what runs by default - it is now written down as data you can read, version, and override.

Inspect what runs

orgabot workflow list                       # every registered definition, with its version
orgabot workflow show orka.default          # stages, transitions, and gates
orgabot workflow show orka.release --json
orgabot workflow resolve release            # what the hierarchy resolves to for an objective shape
orgabot workflow resolve repository_change --project orgabot --org org_senternet
orgabot workflow plan "explain how reviews work"   # what kind of outcome this reads as, before any project

Everything here is read-only. Inspecting a workflow changes nothing.

orgabot workflow plan answers the question the dashboard composer asks first: what kind of outcome is this, and does it need a project at all? It prints the shape Orgabot read from the text, every shape available right now with its definition of done, and which of them can run with no repository. Conversation, bounded interactive work, troubleshooting, research and report, and review or audit can; issue creation, repository change, external operation, Orgabot control, and release cannot, and say so. Both the CLI and the dashboard read this one answer, so they cannot disagree about which turns need a target. Pass --shape to state the outcome yourself instead of accepting the reading, and --json for the whole plan.

The dashboard's New Mission terminal has two launch modes, chosen from the first field of its routing bar and remembered in the browser. Auto mode, the default, answers every question Orgabot can answer itself: a confident workflow reading is not confirmed (an instruction like "the like, share, and save buttons on highwire need recognizable icons" is a repository change and goes straight to routing), and a Chief of Staff plan that needs no human is approved and launched as proposed. A plan still stops for you when something in it is your call: an approval reason, a low-confidence assignment, or a node no role can staff. A project with no verify command is not one of those: verify is optional, so the plan says what will not be machine-checked and still launches, and the gap is reported as an incident. Every gate runs either way; auto mode removes the click, never the check. Plan mode shows the workflow confirmation and the routing review every time. In both modes the routing review states the workflow that was read, the repository milestone that counts as done, and why, with a Change button that reopens the choice, and choosing a workflow in the bar always wins over the reading.

A conflicting reading never guesses. When the text carries both a repository-change signal and a non-delivery signal (or two non-delivery readings that disagree with each other), resolution does not silently pick a side: it proposes the SAFER, non-delivery shape and requires you to confirm it, exactly like an ambiguous reading always has. The composer shows this as an ordinary workflow confirmation; the CLI holds the launch with a workflow_shape_unresolved code naming the exact remedy. Answer it with --shape <shape> to state the outcome yourself, or --yes to accept the proposed safer default under automation (a background launch with nobody watching). Once answered - by --shape, a confirmed composer choice, or --yes - the shape is pinned on the mission's workflow contract and nothing downstream re-derives it, including a decomposed objective node (which inherits its parent's pinned shape rather than re-reading its own rewritten sub-goal) and a follow-up round on the same mission.

A mission that already holds on workflow_shape_unresolved - launched from a script, a follow-up, or any other non-interactive path - does not need a CLI relaunch to answer it. Its drill-down page in the dashboard shows both candidate shapes, the safer default offered first, and confirming either one relaunches the same mission under the same id with that shape pinned exactly as --shape would. The mission stays held, with its branch and worktree intact, until you actually confirm one.

An issue-sourced mission (the auto-sweep, the dashboard's Fix button) resolves its shape from the ISSUE's own labels and acceptance list first, never from Orgabot's own Fix issue <n>: "..." headline, which always reads as a confident repository-change imperative on its own. A bug/enhancement label with a stated acceptance checklist is a repository change; a design/question label is not; contradictory or incomplete label metadata (a delivery label with no checklist, or delivery and non-delivery labels together) holds the launch rather than guessing, the same way an ambiguous prose reading does.

orgabot workflow resolve reads the same policy and configuration a mission launch reads, so what it prints is what a mission would actually get - including a refusal, and including a block when either file is unreadable.

A definition declares stages (intake, resolve, plan, implement, verify, review, deliver, hold, promote, terminal), the transitions between them, the gates each stage must clear, the actor that performs the work, and the evidence it is expected to produce. Each stage also names the events it emits, which is what lets Orgabot check the description against what missions actually do rather than trusting it.

Versions are pinned

A definition is immutable once referenced: re-registering orka.default@1 with different content is refused, and publishing a change means publishing orka.default@2. A workflow run records the exact version it executed, so reading what a mission did years later does not depend on nobody having edited a file since.

Resolution: organization, then project, then mission

Configuration resolves down a hierarchy, most specific winning:

Organization default -> Project default -> Mission override

A level may replace the whole workflow, or override only part of it - one gate's configuration, or the environment chain - and inherit the rest. orgabot workflow resolve prints which level each decision came from.

The organization and project levels live in ~/.orgabot/workflowConfig.json:

{
  "organizations": { "org_senternet": { "workflow": { "workflowId": "orka.default", "version": 1 } } },
  "projects": { "orgabot": { "gates": { "review.independent": "enabled" }, "environments": ["local"] } }
}

Both this file and the optional ~/.orgabot/workflowPolicy.json are fail-closed: absent means "nothing configured here", but present-and-unreadable blocks every launch that would have read it, rather than being silently ignored. An ignored configuration file drops whatever you wrote in it, in either direction.

Every mission resolves its workflow before it runs

Resolution happens at launch, before planning and before any worker opens. Two things can come back:

  • Proceed - the resolved definition is what the mission executes, its version is pinned, and every gate it declares is one Orgabot can decide. The mission logs which workflow and policy it is running under.
  • Hold - the mission stops with its record, branch, and worktree intact, and the refusal lands on the event stream as mission.held, so the notification sink and orgabot timeline both see it. Nothing is planned and no pull request is opened.

Two hold codes come from this gate:

CodeMeaning
workflow_policy_refusedThe resolved workflow omits a policy-required gate, tried to disable a locked one, does not serve the objective's shape, or the policy/configuration could not be read.
workflow_gate_unexecutableThe resolved workflow declares an active gate this runner cannot decide - a release confirmation, for example. Running anyway would treat an undecided gate as satisfied.
workflow_gate_rejectedA gate rejected the change and its retries are spent. The mission holds on that gate with its branch, its commit, and its worktree intact - it is not a failed mission.
workflow_deliverable_mismatchThe turn's workflow does not end in a pull request, and it produced a repository change anyway. The commit stays on the mission's branch; nothing is pushed, opened, or merged, because it passed no gate.

The second is the complement of "a workflow may not drop a gate": a runner may not silently skip one either.

Gates are one mechanism

Verification, independent review, human approval, security review, and production sign-off are all gates: a control point that decides whether progression is allowed. Every gate has an assignment (an automatic system, a human, an agent, an organizational role, or a worker chain), a configuration (enabled, disabled, inherited, or locked), an evidence requirement, and exactly two outcomes.

  • Approved - the run continues.
  • Rejected - the run returns to the builder with the feedback, and the gate is retried.

A rejection is a workflow loop, not a failed mission. A review that requested changes and a worker that died are different things, and they stay different in the journal, in the dashboard, and in orgabot timeline. A rejected gate returns the run to the gate's builder stage with the feedback attached and retries it; when the retries are spent, the mission holds under workflow_gate_rejected (status waiting) rather than being marked failed. Fix the red gate on the mission's branch and resume it, or send a follow-up instruction.

How many retries a gate gets is the definition's call. Each gate declares maxRetries and the stage a rejection returns to (orgabot workflow show orka.default prints both), and the run obeys what the executing definition says rather than a constant inside the runner.

Three properties hold for every gate, not as special cases in the orchestrator but as properties of the mechanism:

  • The producer of a change can never satisfy a gate over that change - including when one actor holds both the producing role and the deciding role.
  • A verification gate approves only on a command exit code of zero. A summary, a confidence, or a worker's own assurance is refused. A diff touching at least one file - including a binary add, delete, or rename - is never "no changes produced".
  • A gate locked by policy cannot be disabled through configuration at any level, and the attempt is refused rather than ignored.

Those properties are enforced where a decision is MADE, not restated at each call site. Verification, independent review, and approval all pass through the same gate model on the way to the journal, so every gate decision records which gate decided, who decided it (with the role they acted under), and the evidence it stood on: the observed exit code of the commands that ran, the review record and its blocking findings, or the resolved approval. That is what orgabot timeline <mission-id> --compliance prints, and it is read from the event stream alone.

A decision the gate model refuses - a verification approved with no exit code behind it, a decider that produced the change - is recorded as refused, not dropped. It shows up as a gap in the compliance evidence and in the replayed run, because a pass the rules did not admit must not read like one they did. A skip is likewise an honest absence of evidence, never a zero: a project with no verify command configured produces no exit code, and the gate can tell that apart from a green suite.

Policy decides what no workflow may omit

Workflow answers "how work normally flows." Policy answers "what cannot be bypassed."

A workflow that omits a policy-required gate is invalid, not permissive, and it is refused when the workflow is resolved - before a run exists - rather than when the missing gate would have run. If the policy itself cannot be read, resolution blocks: an unreadable constraint is not an absent one.

Loosening a policy - removing a required gate, lowering an evidence requirement, narrowing where a requirement applies - routes through the approval queue for the supervising role and applies only with a resolved approval. Tightening applies directly. No instruction text can loosen a policy; authority comes from the brokered approval path only.

Narrowing counts even when the requirement is still listed. Moving a requirement is narrowing too: pointing the verification requirement at deliver stages instead of verify, or at the local environment instead of prod, leaves the original scope unguarded while the entry still reads as present. So does reassigning or dropping the supervising role - that decides who may weaken the requirement next, and a proposal does not get to pick its own approver. Each of those routes to the role supervising the requirement as policy declares it today, and the approval Orgabot mints names the effect in plain terms ("baseline.verification moves off verify stages onto deliver") so the decision is made on what actually changes.

That rule holds for the file too. ~/.orgabot/workflowPolicy.json is a proposal, not an authority: it is compared against Orgabot's baseline policy on every read, and one that is weaker is refused with the reason, exactly as a proposed change would be. A file saying "requiredGates": [] does not remove every gate - it blocks every launch until you either restore what it drops or name a resolved approval in it:

{ "id": "orka.baseline", "version": 2, "requiredGates": [ ... ], "approvalId": "apr_1a2b3c4d" }

approvalId must be an approval you resolved with orgabot approve; an unresolved or denied id is refused rather than queued again. Free-text justification is deliberately not a field.

The approval is bound to the exact policy body it was minted for. It is not enough to name some approved record: an approval you granted last week for an irreversible command is consent to that command, not to deleting evidence-over-confidence for every future mission. Mint the bound approval from the override currently on disk, then resolve it:

orgabot workflow policy-approval     # prints the pending approval id, and what the override drops
orgabot approve apr_1a2b3c4d         # your decision, on that exact policy

Editing the override after approving it invalidates the approval, and the next read blocks again. Reordering the gate list or rewording a description does not: the binding is over what is enforced. The id must also look like one Orgabot minted (apr_…) - a path is refused before it is ever read, so a file outside ~/.orgabot cannot supply its own answer.

A required gate's kind must be one Orgabot knows or one a registered workflow declares. A typo like "verificaton" is refused at the file rather than accepted as a new requirement no workflow can ever satisfy.

Turns that do not end in a pull request

Not every instruction is a code change. "What is the status of mission m-42", "cancel the queued mission", "file an issue for the flaky test" are answers, control actions, and external effects - and a pipeline that ends in a pull request answers all three the same wrong way: with a diff that does not exist, and a terminal that reads "no changes produced".

So a workflow declares what its terminal actually delivers, and each objective shape resolves to one whose terminal matches:

ShapeWorkflowDelivers
repository_changeorka.defaultpull_request
releaseorka.releaserelease_artifact
conversationalorka.conversationalanswer
controlorka.controlcontrol_action
external_operationorka.external_operationexternal_effect
issue_managementorka.issue_managementissue_state

orgabot workflow show <id> prints the terminal alongside the shapes a definition serves, and a launched mission logs both.

A conversational turn completes on the worker's report - that text is the deliverable, carried into the mission summary - and a turn that produced no report at all is a failure rather than a quiet success, because there is nothing else it could have delivered. Nothing is pushed, no pull request opens, and nothing merges.

Which gates apply to a turn that produces no diff is a policy decision, and it is made in the baseline rather than in configuration. The verification, independent-review, and merge requirements are scoped to the diff-producing shapes: there is no change for the project's commands to be evidence about, no diff for an independent reviewer to read, and no draft pull request to promote. The three non-delivery workflows therefore declare no gates - absent, not disabled, because a disabled gate reads as one somebody turned off.

What holds those turns safe instead is structural, and it is what makes removing the gates not a weakening:

  • A definition whose terminal is an answer, a control action, or an external effect may not also serve `repository_change` or `release`. That is refused when the definition is registered, so one of these can never become the route a code change ships by.
  • A runner executing one takes no outward action. If the turn produced a repository change anyway, the work is committed to the mission's own branch and the mission holds under workflow_deliverable_mismatch - it is not pushed, opened, or merged, because it has passed no gate. Re-launch with --shape repository_change to deliver it through the pull-request pipeline.
  • Authority for an external operation is the broker's, checked when the action is performed. A workflow gate decided afterwards would approve something that already happened.

A follow-up stays in the lifecycle its parent ran. The shape a mission resolved to is recorded on it, and a later round inherits it rather than re-reading a shape out of its own instruction - "continue" carries no shape signal, so re-classifying it would hand a conversational turn the pull-request lifecycle its parent deliberately was not running. An explicit --shape is still yours to override with.

The classifier stays deliberately conservative in the same direction it always was: an instruction carrying any repository-change signal ("fix", "refactor", "add a test", and the vocabulary of a product change: what a thing needs or should do, a named button, page, or component, an edit verb such as "update" or "replace", or a defect such as "broken" or "misaligned") reads as a repository change even when it is phrased as a question. The cost of a wrong reading is asymmetric - a mis-read code change would hold on its own diff rather than deliver it - so state the shape when the wording is against you:

orgabot mission orgabot "what is the status of mission m-42?" --shape conversational
orgabot mission orgabot "explain the retry budget in code comments" --shape repository_change

Asking for an issue is the one exception, and it has to be. "File an issue for X", "open a ticket about X", "new highwire issue: ..." asks for the issue, never for X - and such a request always describes the change it wants written down, so its own wording carries "add", "fix", and "implement". Under the rule above that wording won, and dictating a feature request produced a pull request implementing it instead of an issue recording it. So an issue-filing phrase is decisive on its own and is not cancelled by the change it describes:

orgabot mission highwire "new highwire issue: the admin should show each candidate's pipeline stage"   # files an issue
orgabot mission highwire "add a pipeline-stage column to the admin"                                     # writes the code

Naming an existing issue number keeps its ordinary meaning: Fix issue 1498: "..." - the form Orgabot's own issue launcher uses - is work on that issue and stays a repository change, whatever the quoted title happens to say.

Acting on an issue is its own shape. "Reopen issue 1852", "close issue 204", "comment on issue 77", "label 412", "assign issue 91", "triage ticket 33" ask for a change to the issue, not to the repository, and they need the issue connector's issues:write and nothing else. They resolve to orka.issue_management, whose terminal is the issue's re-observed state: no branch, no diff, no pull request, and therefore no contents:write or pull_requests:write in the delivery preflight. Before this shape existed, the justification an operator writes for reopening ("the terminal should minimize when I press the launch button") read as a repository change, and the mission held for push and pull-request permissions a curator role rightly does not hold - offering, as its remedy, permanently widening that role's grant.

The verb has to lead the instruction, because an objective routinely quotes other issues in passing: "fix the crash and close issue 88 when it ships" is still a repository change. Name --shape issue_management when your wording puts the verb somewhere else.

orgabot mission orgabot "reopen issue 1852 because the animated effects still are not there"   # reopens the issue
orgabot mission orgabot "fix issue 1852"                                                        # writes the code

Completion is a provider re-read of the issue the instruction named, keyed on its number rather than on a guessed title: a reopen completes when the provider reports it OPEN, a close when it reports CLOSED, and a comment, label, or assignment when a comment posted during the mission is observed on it. A worker's report that it reopened something is a claim; the provider settles it. And the brief follows the shape: a turn that delivers an issue is not asked to run the build, add tests, or edit files, because none of those is what it delivers. As always, --shape settles it when the wording is against you.

Environments and releases

local -> prod and local -> dev -> stage -> prod are the same construct at different lengths, and the chain resolves down the same hierarchy. The important rule: the target environment is the next required promotion point, not the final destination. A mission promoting toward prod through a four-stage chain targets dev first, and clears each stage's gates on the way.

Release work uses typed operations - create_tag, push_tag, publish_release, observe_release - that take a repository, a tag, and the expected commit SHA, not free text. A release objective completes only after Orgabot independently re-observes the outcome: the tag exists at the expected SHA and the release exists. Confirming that a pull request is open is not confirmation of a release, and an observation that could not be read confirms nothing at all.

In practice today: an objective that reads as release-shaped ("tag premail v1.0.0 and push the tag") resolves to orka.release, whose promote stage carries a release-confirmation gate the pull-request runner cannot decide. So the mission holds and says so, naming the release steps. It does not open a version-bump pull request and report the objective complete, which is the failure that motivated this design.

A release mission that does reach the delivery pipeline still cannot finish on a pull request. Shape resolution reads the objective's first line, so a release ask phrased some other way, or arriving as a follow-up round on an existing branch, can still end up on the ordinary edit -> verify -> review -> pull request path. Before that path records completed, Orgabot reads the repository's own tags and releases back and asks whether the requested tag is there. If it is not, the mission is left waiting with its branch and its pull request intact, saying which tag is missing, and orgabot release promote is what finishes it. Both spellings of a version are accepted (v1.0.0 and 1.0.0), because the repository decides that convention and not the phrasing of the instruction. A read that fails confirms nothing and holds the same way: an unreadable answer is not an answer.

A completed mission may not contradict its own verification evidence. A record that says status completed, verification failed, and verifyPassed: true at the same time is one every surface reads differently, so it is refused at the point the mission becomes terminal: an observed verification failure for the delivery in hand downgrades completed to waiting and drops the boolean, which is only a projection of the structured evidence. Deferred, queued, and skipped verification are untouched - those are absent observations, not failures, and the gates that consume them already refuse to read either as a pass.

Running a promotion

The promotion itself is a separate, explicitly-invoked command, because it is the one runner that can decide a release confirmation:

orgabot release plan premail --tag v1.0.0 --sha <full-40-char-sha>      # read-only
orgabot release promote premail --tag v1.0.0 --sha <full-40-char-sha>   # executes

plan prints the next hop on the chain, the typed operations it would perform, and which of them need approval. It changes nothing.

promote establishes authority first, and it always needs a resolved approval minted for those exact operations. The approval carries a digest of the operations, the expected SHA, and the release title that will be published, so an approval granted for one promotion cannot authorize another - not a different tag, not the same tag at a different commit, not the same release under a different public title. Without it the command holds with nothing performed, mints the request (or reuses the pending one from an earlier run, rather than filling your queue with duplicates), and tells you to approve it. It never runs "the safe subset": a half-promoted release is worse to diagnose than an unstarted one.

The approval is required by the release workflow's own promote.authority gate, which is a separate thing from your project's autonomy policy. A permissive project auto-approves pushing a branch, and --no-release reduces a promotion to creating and pushing a tag - so reading only the policy would let a production tag, the thing tag-triggered release CI fires on, go out with no approval at all. The workflow gate is the tightening, and a permissive policy cannot erase it. The decision is recorded either way: an approved promotion records which approval it stood on, and a refused one records the gap, so orgabot timeline --compliance shows a promotion that held rather than showing nothing.

An operation's approval class comes from the environment it promotes into, read from that environment's registered spec rather than from its name. An organization that registers its own environment as always_confirm gets production-grade sign-off for promotions into it, and an environment nothing has registered is treated as the strictest, never the most permissive.

Then it performs the operations, forgets what they claimed, and reads the world back. The gate approves only on that re-observation. A gh call that returned success is not a release; the tag existing at the expected SHA is. A tag pointing at a different commit, a release still in draft, an operation that failed part-way, or a read that could not complete at all - each of those reports not confirmed, and says which.

The expected SHA is required and must be the full 40 characters. "The latest commit" is not a release target: it is whatever the branch happened to be when the command ran, which is the ambiguity a release exists to remove. Creating a tag is idempotent when it already points at the expected commit, and refused outright when it points anywhere else - Orgabot never moves an existing tag.

The shape is read from the objective's first line, not from the whole body. A launched objective is routinely a pasted issue that quotes a release while asking for an ordinary code change, and holding every one of those would be a worse failure than the one this gate prevents. The cost of that choice, stated plainly: a release ask buried below the first line reads as a repository change. State it explicitly instead of arguing with the text:

orgabot mission premail "cut a release for v1.0.0" --shape release
orgabot mission orgabot "fix the tag helper" --shape repository_change

Sources and sinks

Every mission carries a machine-readable source: the terminal, the dashboard composer, a GitHub issue, the API, a scheduled duty, a recovery sweep, a stranded-pull-request resumption, an incident repair. Each of those is a registered origin, and a mission may only record an origin something registered - an unregistered id is refused at creation, because provenance nothing can resolve is a label, not an answer. Adding an origin (an embedder's own app, say) is that registration and nothing else; no orchestrator code learns about it.

Provenance travels with the mission for the life of its correlation, and context keeps the trust label it arrived with - issue text is external content when it enters and external content when it reaches the brief. That label is the one the worker is actually shown: an issue-driven mission's instruction is handed over as [external_content], while the same words typed into a terminal are [user_instruction]. The origin only ever lowers the label, never raises it, so a source cannot talk its way into more authority than the entrypoint it came from - and a label never granted authority in the first place. It is also the real origin rather than a default: a mission composed in the dashboard records the dashboard, and a scheduled duty records the schedule, so the stream can tell either apart from an operator typing the same instruction into a terminal.

Sinks subscribe to one event stream rather than being wired at individual call sites: memory, analytics, GitHub, documentation, and notification. The notification sink is what makes "a human is needed" reach a human, and it names the exact next action rather than announcing that something happened:

Mission m-42 is held: capability_denied - needs the github connector
  next: orgabot approvals   - grant the missing capability, then: orgabot follow-up m-42 "continue"

Completed is not the same as done

A mission can finish green and still be inert until you do something: a key to set, a dashboard toggle to flip, a migration to run. That mission is not held and not failed, so it reads as success on every surface, which is exactly why nobody looks at it.

So every worker is asked to end its report with an ## Operator follow-up section listing what a human must still do (or None). When a mission becomes terminal, Orgabot reads that section off the mission's own report, records it on the mission, and carries it on the journal's mission.completed. If a report has no such section, a deliberately narrow scan still catches the common shape: a sentence that puts an obligation on you and names a configuration identifier, like NEXT_PUBLIC_AHREFS_KEY. A declared None suppresses that scan, including the bulleted form (- None.) the worker brief asks for, because the sentence saying no alert is needed must not become the alert.

The result shows up in three places: at the end of the mission log, as an attention notification through every configured transport, and as a Completed, needs you item in the dashboard inbox, ranked above merely-unshipped work.

Mission mission-msf4oa78-0-i183 completed but still needs you (1 manual step)
  next: NEXT_PUBLIC_AHREFS_KEY: Set NEXT_PUBLIC_AHREFS_KEY in the App Hosting backend config.

A completion with nothing owed produces no notification at all, and does not consume an external transport's rate budget either. The report is quoted to you, never executed, and it is redacted, flattened to one line, and bounded before it travels.

One thing is deliberately shortened on the way out. A step whose subject looks like a secret (*_KEY, *_TOKEN, *_SECRET) reaches Slack or Teams as the name plus a pointer (NEXT_PUBLIC_AHREFS_KEY must be set - see: orgabot logs <mission>) and never as the worker's own sentence, because the sentence naming a key is the one most likely to sit beside its value. The full wording still reaches you on the mission log and in the dashboard inbox, which is where it was asked for.

Sink delivery never blocks a mission, and a failing sink is surfaced, not swallowed. A delivery that fails is retried on later passes, to the sink that failed and to no other; after five attempts it is written to ~/.orgabot/sinkDeadLetter.jsonl and reported. A single write error on a full or read-only ~/.orgabot therefore cannot be the end of a "a human is needed" event. Redaction happens once at the envelope, before any sink sees a payload, so no sink can receive a secret the audit log would have scrubbed. External sinks post at a bounded rate per time window; a closed rate window is not a failure and does not consume a retry.

Delivery runs on the dashboard's own timer (never from a page load), reading each mission's stream and dispatching only what a cursor has not already seen. Each pass considers a bounded number of missions, and missions that may be waiting on a human are considered first - a held mission stops heartbeating, so ordering by recency alone would push exactly the wrong mission out of the window. A mission the pump sees for the first time has its cursor seeded rather than its history replayed - being notified about a hold you resolved last week is how operators learn to ignore notifications. Every notification is also recorded, so a banner you missed is still readable:

orgabot notifications            # what needed a human, each with the exact next action
orgabot notifications --json

The notification log lives at ~/.orgabot/notifications.jsonl and is written even when banners are suppressed (ORGABOT_NO_NOTIFY, or a non-macOS host).

Slack and Teams

A banner on your laptop is silent the moment you are not at your laptop, which is the same failure one hop out. So the notification sink is subscribed once per transport: the built-in local one always, plus any Slack or Teams webhook you configure. Adding one is a config entry, not code.

~/.orgabot/notificationTransports.json:

{
  "version": 1,
  "transports": [
    { "id": "slack-main", "kind": "slack", "urlEnv": "ORGABOT_SLACK_WEBHOOK_URL" },
    { "id": "teams-ops", "kind": "teams", "urlEnv": "ORGABOT_TEAMS_WEBHOOK_URL", "rate": { "max": 10, "windowMs": 60000 } }
  ]
}

The file names an environment variable; it never holds the URL. A webhook URL is a bearer credential and, in the other direction, a destination, and anything running on the host with a sufficiently broad shell grant can write ~/.orgabot. Naming a variable means a file writer can propose a destination but cannot supply one. The name must begin with ORGABOT_ (or the legacy ORKA_, which is still read), so a hand-edited file cannot point at an unrelated secret in the dashboard's environment. Export the variable where the dashboard runs:

export ORGABOT_SLACK_WEBHOOK_URL="https://hooks.slack.com/services/…"

A resolved URL is then checked against a per-kind host allowlist (hooks.slack.com for Slack, *.webhook.office.com or *.logic.azure.com for Teams) over https only. A Slack transport posts to Slack and nowhere else, so a config entry cannot become an exfiltration route. Only the notification's derived fields travel (title, next action, mission id); the event payload never does. A non-2xx response is a failure, so it is surfaced and retried rather than read as delivered, and the URL is scrubbed out of every error before it reaches the console, the cursor file, or the dead-letter log.

Each external transport posts at a bounded rate per time window (20/minute by default), so a mission flapping between held and running cannot become a channel people mute.

orgabot notifications transports          # where a notification would actually go
orgabot notifications transports --json

That reports, per transport, whether its variable is set and whether the value passes the host check, without ever printing the value. A transport that is misconfigured is reported at dashboard startup and is simply not subscribed; it never costs you the local notification.

The documentation sink is subscribed to the same stream and keeps a changelog of what actually shipped - a mission completing, a delivery merging - derived from the stream alone, so it never reads a working tree:

orgabot workflow changelog       # what shipped, newest last
orgabot workflow changelog --json --limit 100

Entries live at ~/.orgabot/changelog.jsonl. Because sink delivery is at-least-once for what fails, a repeat of a fact already recorded is dropped rather than written twice: a changelog that lists one release twice is wrong in the way a changelog is read.

The timeline is replayed, not stored

Every stage, gate, and transition is an event in one envelope with event_id, correlation_id, causation_id, and workflow_run_id. The mission timeline is reconstructed by replaying that stream:

orgabot timeline <mission-id>               # the mission story, rebuilt from events alone
orgabot timeline <mission-id> --compliance  # gate decisions, deciders, and cited evidence
orgabot timeline <mission-id> --json      # the entries plus the replayed workflow run

--json answers { "timeline": [...], "workflowRun": {...} }. The run block carries the workflow-run id, the pinned definition the mission actually executed, the current stage, and an outcome that distinguishes gate_rejected (a loop the run can leave) from mission_failed (a terminal it cannot). The dashboard reads the same block from GET /api/missions/:id/timeline.

Each event after workflow resolution also carries workflow_id and workflow_version, so a replay reads the run against the definition that ran rather than assuming today's default. A journal written before that stamp existed replays against the built-in default and says so (definitionProvenance: "assumed").

Replay is deterministic and side-effect free: it appends nothing, runs nothing, and fires no sink, which is why the dashboard can render it on every poll - the Work tab's correlation view serves the whole story (every mission on the correlation, interleaved) from that replay rather than from a separate store. Because that view is polled, the correlation replay is bounded to the most recent missions and entries, and the view says so when it drops any.

The correlation record still indexes what participated in a lineage - an issue, a pull request, a CI run are facts nobody journals - but it is not a second record of anything the stream owns. Each mission row on that view takes its status from the mission journal, so a correlation file that was last written three rounds ago cannot show a stale status, and a mission whose record was lost still reads correctly. A mission with no journal at all keeps whatever the index had: an absent stream is not evidence that the stored detail is wrong.

--compliance answers the whole audit question from the stream and nothing else - no working tree, no mission record, no GitHub read. Alongside the gate decisions it names the project and the pinned workflow definition the run executed, where the mission came from, the actors that produced the change (distinct from the ones that decided gates over it, so reviewer-is-not-producer is re-checkable from the record), what was delivered and where, and how the mission ended or what it is held on. Anything the stream cannot answer is reported as a gap rather than quietly sourced from somewhere else - including a stream with no pinned definition, where the record says which gates fired but cannot say which ones the run was required to clear.

There is one authority. The workflow runtime drives the existing mission lifecycle journal; the workflow run is a projection of that journal, exactly as the mission's status is. A second lifecycle store would be a second truth, which is the failure this design exists to avoid.

Where this is going

The decision is ADR 0062. Definitions, resolution, gates, policy, environments, sources, sinks, and replay are in place; every mission resolves its workflow before it runs, provenance is recorded at creation, and the notification sink is subscribed to the live stream.

Conversational, control, and external-operation turns now resolve to workflows whose terminal is not a pull request, under a baseline policy decision about which gates apply to a turn that produces no diff, and a follow-up inherits the shape its parent ran.

What is not done yet, stated plainly: a release-shaped mission still holds at the promotion gate rather than promoting on its own. The promotion runner exists and is governed and confirming (orgabot release promote), but an operator invokes it; wiring a held mission through to it, so the mission resumes and completes on the confirmation, is the next step - and it needs the tag and the expected SHA to come from somewhere Orgabot can trust, which is why it is not simply reading them out of the objective. That piece is tracked in issue 1454. Migrating the memory, analytics, and GitHub writers off their individual call sites and onto their sink subscriptions is still ahead - they still write where they are called, so subscribing them as well would double-write. Registering an organization's own workflow definitions from a configuration file (rather than selecting among the built-in ones) is the other open piece.