Spec
Event Model
Purpose
The Event Model is the contract between Integrations and the Router. It exists to keep the router (and everything downstream) free of platform-specific shapes — GitHub webhook JSON, Slack Bolt event objects, raw HTTP bodies — without losing the information those payloads carry.
If you add a new event source, you implement one new Connector that
produces this shape. Nothing else changes.
Schema
export interface EventEnvelope {
/** Unique event ID — used for dedup. */
id: string;
/** Source connector name: "github" | "slack" | (future) "discord", etc. */
source: string;
/** Normalized event type (see EventType). */
type: EventType;
/** Repository in owner/repo form. Absent for non-repo-scoped events. */
repo?: string;
/** Issue or PR number on GitHub. */
issueNumber?: number;
/** PR number, distinct from issueNumber for PR-only routing decisions. */
prNumber?: number;
/** Head commit SHA for PR-shaped events (`pr.checks_passed` / `pr.checks_failed`
* carry the settled check_suite's head_sha). Lets the dependency-workflow
* dedup guard skip a PR already assessed at this exact SHA. */
headSha?: string;
/** Is this a dependency-update (Dependabot / Renovate) PR? Set on the
* check-outcome events, where the connector already computes it — from the
* head commit's author and the suite's head branch — to decide whether to
* emit at all. Carried rather than discarded so the router can route
* `pr.checks_failed` deterministically instead of paying a classifier call
* to re-derive it from a prose sentence. */
isDependencyPr?: boolean;
/** Login / username of the originator. */
sender: string;
/** Login of the issue/PR original author — distinct from `sender` (the
* commenter). Used to detect reporter replies for re-triage. */
issueAuthor?: string;
/** True if the originator is a bot — used for self-loop checks. */
senderIsBot: boolean;
/** Event body text — issue body, comment body, PR description, message text. */
body: string;
/** Title — issues and PRs only. */
title?: string;
/** Labels currently on the issue/PR at event time. Snapshot, not delta. */
labels?: string[];
/** GitHub author_association: OWNER, MEMBER, COLLABORATOR, CONTRIBUTOR, NONE. */
authorAssociation?: string;
/** Original platform payload. Connector-specific data goes here. */
raw: unknown;
/** Post a reply on the same platform / thread. Fire-and-forget. */
reply: (msg: string) => Promise<void>;
/** Event time, set by the connector. */
timestamp: Date;
}
export type EventType =
| "issue.opened"
| "issue.reopened"
| "issue.closed"
| "pr.opened"
| "pr.synchronize" // new commits pushed to a PR
| "pr.reopened"
| "pr.closed"
| "pr.merged"
| "pr.checks_failed" // checks settled RED (aggregate) on a dependency PR,
// or on any PR whose head commit the bot pushed
| "pr.checks_passed" // a dependency PR's checks settled GREEN (aggregate)
| "pr.checks_settled" // checks settled EITHER COLOUR on a PR neither of the
// two above claimed — `review.trigger: after-checks`
| "pr.labeled" // a label was added; carries `addedLabel`
| "pr.review_requested" // a review was asked of us by name, or our own
// `last-light/review` check's Re-run was pressed
| "comment.created"
| "pr_review.submitted"
| "pr_review_comment.created"
| "message"; // generic chat-platform message (Slack today)
Required fields: id, source, type, sender, senderIsBot,
body, raw, reply, timestamp (nine). Everything else is
platform-conditional.
Defined in src/connectors/types.ts. Imported as EventEnvelope /
EventType across the codebase — there is exactly one definition.
Behaviour
Connectors build EventEnvelope literals directly — there is no
builder helper, no validation layer between connector and router. The
router trusts the connector to produce a conforming object.
The order of events through the system:
- Connector receives a platform payload.
- Connector runs auth (HMAC, allowlist, etc.).
- Connector decides whether the payload should produce an envelope at
all. Many GitHub actions (
edited,unlabeled,assigned, …) drop here.labeledno longer does —review.requestLabelis the realon-requestreview mechanism, since a GitHub App bot user cannot be picked in the reviewer dropdown — but a label on an issue still falls out with a null type, and the router hard-ignores every PR label that is not the configured one, so the widening costs anormalize()call rather than a dispatch. See Integrations. - Connector constructs the envelope and emits
event. ConnectorRegistryforwards it to the central handler in the harness.- The harness calls
routeEvent(envelope)(Router) and dispatches the matched workflow or skill.
The envelope is read in steps 5 and 6; from then on it lives only in the workflow context where dispatched code may pull fields from it.
Platform field availability
| Field | GitHub events | Slack message |
|---|---|---|
repo |
always | never |
issueNumber |
issues + PRs + comments + reviews | never |
prNumber |
PR events + PR comments only | never |
headSha |
pr.checks_passed / pr.checks_failed / pr.checks_settled (the settled suite’s head SHA) |
never |
isDependencyPr |
pr.checks_passed (always true) / pr.checks_failed (true for a bump, false for a PR the bot pushed to) / pr.checks_settled |
never |
addedLabel |
pr.labeled only — the label just added, matched against review.requestLabel |
never |
requestedReviewer |
pr.review_requested only — a login, or team/<slug> for a team request; set to our own botLogin when the request arrived as a Re-run on the last-light/review check |
never |
title |
issues + PRs (+ comments via parent) | never |
issueAuthor |
issues + PRs + comments (parent author) | never |
labels |
issues + PRs (snapshot at event time) | never |
authorAssociation |
always (see below) | never |
senderIsBot |
always false (bot self-events are filtered at the connector) |
always false |
raw.sessionId / channelId / threadId |
n/a | always (Slack — session routing) |
For Slack, channel id, thread id, and platform user id live in
envelope.raw, not on the top-level envelope. The canonical schema
stays small; platform extras stay in raw. The chat skill reads them
back when it needs to route a reply to the right thread.
authorAssociation
GitHub-only. Values: OWNER, MEMBER, COLLABORATOR, CONTRIBUTOR,
NONE. The router treats the first three as “maintainer”:
// src/engine/router.ts:28
const MAINTAINER_ROLES = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
@last-light build commands from a CONTRIBUTOR or NONE get a
polite decline (router.ts:123–130) and never reach a workflow.
reply()
reply: (msg: string) => Promise<void>;
- GitHub — posts a comment on the source issue or PR via the
configured
replyFn(owner, repo, issueNumber, msg). (github-webhook.ts:237) - Slack — calls
sendMessage(channelId, threadId, chunk)for each chunk of a chunked message, replying into the originating thread. (base.ts:89–99)
Both are async, both return Promise<void>. Neither returns the
resulting artifact (comment URL, message ts). Callers that need the
artifact must fetch it via the platform API separately.
reply() is fire-and-forget — GitHub’s variant silently no-ops if
replyFn or issue context is missing; Slack’s is wrapped in .catch()
at the call site (src/index.ts:994). A re-implementation that wants
to surface delivery failures must thread errors through explicitly.
Being a closure on the envelope is what makes it decoratable: the dispatcher
replaces a messaging envelope’s reply with one that also records the message
into the thread’s conversation (withThreadTranscript), so every non-chat
messaging path gets a transcript without any handler knowing. The decorator
sends first and records after, and a send failure still propagates.
raw
The original platform payload, plus connector-attached metadata for Slack:
// github-webhook.ts:256
raw: payload // the full GitHub webhook body, unmodified
// base.ts:109–115
raw: {
...(typeof raw === "object" && raw !== null ? raw : {}),
sessionId,
platformUserId,
channelId,
threadId,
}
Downstream code reads raw rarely — the harness pulls raw.comment?.id
to react to a triggering GitHub comment (src/index.ts:942), and the
chat skill pulls session ids out of Slack’s raw for thread-resumption
after a harness restart (src/index.ts:192–194). The router itself
never inspects raw — all routing decisions use top-level fields.
Invariants
- One canonical definition. Every consumer imports
EventEnvelope/EventTypefromsrc/connectors/types.ts. There is no duplicate or platform-extended version. labelsis a snapshot, not a delta. If a label is added after the event, the original envelope still reflects the old set. This is intentional — events are immutable.senderIsBot: truedoes not exist in practice. Both connectors filter bot events upstream (or set the fieldfalsebecause there is no bot path that produces an envelope). Code that branches onsenderIsBot === trueis dead.type === "message"is the only chat-platform type. No Slack- specific subtypes (message.app_mention,message.dm). Disambiguation inside chat happens via fields inrawand by the router examining the body for@last-lightmentions.- No factory. Connectors build literals inline. A re-implementation may add a builder helper but should not add a validation step — the connector is the contract.
- Not everything a platform sends is an event here. A Slack emoji
reaction (issue #255) is deliberately NOT normalized into an envelope. The
envelope pipeline exists to decide what work to do — it runs the message
batcher, the intent classifier and the PR dispatch gate — and a 👍 asks for
none of that: it is a fact to record about work already done. Routing it
through would mean widening the closed
EventTypeunion, teaching the batcher to skip it, and teaching the classifier to ignore it, for no dispatch. It goes through a direct connector callback instead (onReactionAction), the same shape as the approval-button hook. - Fields look optional but aren’t, for some events. A workflow that
expects
reposhould refuse to run ifenvelope.repois missing. The schema is permissive; the consumers’ contracts are not.
Current implementation
| Piece | File |
|---|---|
EventEnvelope, EventType, Connector |
src/connectors/types.ts |
| GitHub normalizer | src/connectors/github-webhook.ts:157–260 |
| Slack / messaging normalizer | src/connectors/messaging/base.ts:47–121 |
| Router consumer | src/engine/router.ts |
| Harness consumer | src/index.ts:560–1124 |
| Test factory | src/engine/router.test.ts:24–38 |
Rebuild notes
- Keep the envelope small. Resist the temptation to add
per-integration fields. Anything platform-specific belongs in
raw. The schema stays comprehensible because every field justifies its presence by being meaningful for at least two integrations. - Treat
rawas opaque from the router’s perspective. A new router decision should be possible without changing the EventEnvelope schema — add a new top-level field only when the data is universal. - Make
typean enum, not a free-form string. Catching a typo at the connector boundary is much cheaper than catching it in a workflow five minutes after dispatch. reply()as a closure on the envelope is the right shape. The alternative — connectors expose areply(envelopeId, msg)method — forces every consumer to know which connector to call into. The closure pattern means callers only need the envelope they already have.- Async, fire-and-forget reply, by default. Most callers don’t
need delivery confirmation. If a re-implementation makes
reply()return a useful value (a comment URL or message ts), it should still be discardable — the common path is “post and move on”. - Snapshot semantics on time-varying fields. Labels, author association, body text — capture them at event time. Re-fetching at workflow-run time creates surprising races where the agent acts on state that didn’t exist when the event fired.