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 from → to |
|---|---|---|
state_changed |
Plane or ship stage update | e.g. agreement → queued, execute → ci |
needs_you |
Human gate raised | — → human_test / human_merge / agent |
cleared |
Gate satisfied or ticket done | human_test → ship |
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:
- Authorization — actor allowlisted (Fullsend ADR-0054)
- Agreement gate — initiative past agree (W2)
- Budget / wallet — D27c not locked
- Fleet size — D24 one worker (until D24b raises concurrency)
- Human gates — never auto-skip
human_test/human_merge(W2b) - 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.
Related Fullsend references¶
| Topic | Link |
|---|---|
| NormalizedEvent normative spec | docs/normative/normalized-event/v1/ |
| CEL dispatch | ADR-0061 |
| Polling discovery | ADR-0063 |
| Forge abstraction | internal/forge/forge.go |