Skip to content

Ship normalized events (D19b dispatch contract)

Status: Envelope, normalizers and gate evaluation shipped in shadow mode (ship/normalized-event.js, ship/event-router.js). Decisions are recorded to router_decisions and readable at GET /api/v1/router/decisions; nothing is ever dispatched — there is no live branch in the code. No CEL engine yet.
Purpose: One forge-neutral vocabulary for auto-dispatch (D19b) so Plane, Forgejo, Woodpecker, and ship workers route on the same event shape instead of ad-hoc bash per stage.

Borrowed from: Fullsend NormalizedEvent v1 + ADR-0061 harness CEL dispatch.
Related: Agent patterns to borrow §11 · Idea → Done (W4 dispatch, D19b) · ship golden path


Why this exists

Today, ship and Woodpecker react to separate webhook shapes:

Source Typical trigger Today
Plane Issue state change Manual / future webhook
Forgejo PR opened, synchronized, merged CI notify → ship ticket update
Woodpecker Pipeline success/failure CI notify → ship ticket update
Ship Work T1, scan dispatch Direct API / poller

D19b auto-dispatch needs a single envelope: what happened, to which ticket/initiative, who/what actor, can we start a worker safely. Fullsend’s NormalizedEvent is the reference; we adapt names to Plane + Forgejo + Woodpecker, not GitHub Issues.


Event envelope (v0 sketch)

Every inbound webhook or poller tick should be mappable to:

{
  "schema": "ship.normalized-event/v0",
  "id": "evt-20260807-plane-PORT-12-state",
  "occurred_at": "2026-08-07T10:15:00Z",
  "source": "plane",
  "transition": {
    "kind": "state_changed",
    "from": "queued",
    "to": "execute"
  },
  "subject": {
    "type": "ticket",
    "id": "PORT-12",
    "initiative_id": "init-portfolio-cta",
    "plane_issue_id": "PORT-12",
    "repo": "rafael.gonzalez.albes/portfolio"
  },
  "actor": {
    "kind": "human",
    "id": "rafael",
    "roles": ["pm"]
  },
  "forge": {
    "pr_number": null,
    "branch": "feat/port-12-cta",
    "pipeline_id": null,
    "pipeline_status": null
  },
  "dispatch_hints": {
    "eligible_for_worker": true,
    "blocked_reason": null,
    "suggested_role": "coder"
  }
}
Field Meaning
schema Contract version for validators
source plane | forgejo | woodpecker | ship | poller
transition.kind Stable verb — see table below
subject Ticket/initiative the event applies to
actor Human, agent, or system that caused the transition
forge Optional PR/branch/CI context
dispatch_hints Advisory — router may override after D25 auth + budget checks

transition.kind vocabulary

Reuse Fullsend naming where it fits Git/CI semantics; add Plane-specific kinds.

Plane / ship ticket lifecycle

transition.kind When Typical fromto
state_changed Plane or ship stage update e.g. agreementqueued, executeci
needs_you Human gate raised — → human_test / human_merge / agent
cleared Gate satisfied or ticket done human_testship
stuck Soak / retry budget exhausted any → blocked
dispatch_requested Work T1 / scan dispatch — → worker queued

Forgejo (PR / branch)

transition.kind When Fullsend parallel
opened PR created opened
synchronized New commits pushed to PR branch synchronized
ready_for_review Draft → ready (if used)
merged PR merged to default branch merged
closed PR closed without merge closed

Woodpecker (CI)

transition.kind When Maps to ship
pipeline_started Build queued/running ticket waiting_on: ci
pipeline_passed Green pipeline advance toward preview/ship
pipeline_failed Red pipeline agent_failed or stuck
pipeline_skipped No-op / filtered pre-script skip (Fullsend skipped)

Example mappings

Plane: ticket ready to code

{
  "schema": "ship.normalized-event/v0",
  "source": "plane",
  "transition": { "kind": "state_changed", "from": "queued", "to": "execute" },
  "subject": { "type": "ticket", "id": "PORT-12", "initiative_id": "init-portfolio-cta" },
  "actor": { "kind": "human", "id": "rafael", "roles": ["pm"] },
  "dispatch_hints": { "eligible_for_worker": true, "suggested_role": "coder" }
}

Router intent (future): if D19b enabled + budget OK + no path conflict (H15) → enqueue ship-worker job.

Forgejo: PR updated after worker push

{
  "schema": "ship.normalized-event/v0",
  "source": "forgejo",
  "transition": { "kind": "synchronized", "from": null, "to": null },
  "subject": { "type": "ticket", "id": "PORT-12", "repo": "rafael.gonzalez.albes/portfolio" },
  "forge": { "pr_number": 5, "branch": "feat/port-12-cta" },
  "actor": { "kind": "agent", "id": "ship-worker", "roles": ["coder"] },
  "dispatch_hints": { "eligible_for_worker": false, "blocked_reason": "await_ci" }
}

Router intent: wait for pipeline_passed; do not re-dispatch coder on every push unless H7 fix loop says so.

Woodpecker: pipeline failed

{
  "schema": "ship.normalized-event/v0",
  "source": "woodpecker",
  "transition": { "kind": "pipeline_failed", "from": "running", "to": "failure" },
  "subject": { "type": "ticket", "id": "PORT-12" },
  "forge": { "pipeline_id": 842, "pipeline_status": "failure" },
  "actor": { "kind": "system", "id": "woodpecker" },
  "dispatch_hints": { "eligible_for_worker": true, "suggested_role": "fixer" }
}

Router intent: optional H7 CI-retry — dispatch fixer role once, respect retry budget.

Ship poller: webhook missed

{
  "schema": "ship.normalized-event/v0",
  "source": "poller",
  "transition": { "kind": "dispatch_requested", "from": null, "to": "queued" },
  "subject": { "type": "ticket", "id": "PORT-12" },
  "actor": { "kind": "system", "id": "ship-poller" },
  "dispatch_hints": { "eligible_for_worker": true, "suggested_role": "coder" }
}

Router intent: Fullsend ADR-0063 complement when Forgejo/Plane webhooks drop.


Dispatch rules (future — not implemented)

When a CEL or JSON-rules router lands, every path must still pass:

  1. Authorization — actor allowlisted (Fullsend ADR-0054)
  2. Agreement gate — initiative past agree (W2)
  3. Budget / wallet — D27c not locked
  4. Fleet size — D24 one worker (until D24b raises concurrency)
  5. Human gates — never auto-skip human_test / human_merge (W2b)
  6. Output schema — worker returns validated JSON before forge writes (Fullsend ADR-0022)

Example expression shape (illustrative only):

transition.kind == "state_changed"
  && subject.to == "execute"
  && dispatch_hints.eligible_for_worker
  && actor.kind != "untrusted_comment"
→ enqueue(role="coder")

Implementation phases

Phase Deliverable Status
Now (this doc) Shared vocabulary + examples; link from borrow catalog ✅ done
Next Normalizer functions in ship: Plane webhook → envelope, CI notify → envelope ship/normalized-event.js
Then Envelope validation + worker output gate validateEnvelope(), scripts/lib/worker-output-gate.mjs (wired into openrouter-coder.mjs, not ship-work-t1.mjs — that file is a laptop briefing helper and never consumes model output)
Then+ Gate evaluation recorded, no dispatch ✅ shadow mode, ship/event-router.js
Later Live auto-dispatch; CEL or small rules engine + behaviour tests (Fullsend e2e/behaviour style) ⬜ open

Known limitation found while implementing

Plane's v2 webhook payload (event, entity_type, entity_id, data, previous_attributes) carries no actor. fromPlaneWebhook therefore reports actor.kind: "unknown", and the authorization gate (ADR-0054) fails closed — so every Plane-sourced event is currently blocked on authorization. That is correct behaviour, not a bug, but it means live dispatch from Plane webhooks is not possible until either the payload carries an actor, the actor is fetched from the Plane API, or Plane is trusted as a source rather than by identity. Woodpecker events are unaffected: they carry a known system actor.


Topic Link
NormalizedEvent normative spec docs/normative/normalized-event/v1/
CEL dispatch ADR-0061
Polling discovery ADR-0063
Forge abstraction internal/forge/forge.go