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"
  | "issue.labeled"      // a label was added to an ISSUE; carries `addedLabel`.
                         // The software-factory pipeline's entry event — the
                         // router keeps it only when the label is a configured
                         // `autonomy.stages.*.enter`
  | "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:

  1. Connector receives a platform payload.

  2. Connector runs auth (HMAC, allowlist, etc.).

  3. Connector decides whether the payload should produce an envelope at all. Many GitHub actions (edited, unlabeled, assigned, …) drop here. labeled no longer does, on either subject: on a PR because review.requestLabel is the real on-request review mechanism (a GitHub App bot user cannot be picked in the reviewer dropdown), and on an issue because a stage label is how the software-factory pipeline starts. The router hard-ignores every label that is not the configured one — the repo’s or operator’s review.requestLabel for a PR, a configured autonomy.stages.*.enter for an issue — so both widenings cost a normalize() call rather than a dispatch. See Integrations.

    unlabeled stays ignored, and that is a decision rather than an omission. Removing a label is never an instruction to do work: the pipeline’s own advance takes the entry label off at dispatch, so an unlabeled route would make every advance produce an event about itself, and the triage/hold vocabulary is read as a live precondition on the next event rather than as a transition. The one removal that does mean something — taking requires-human off an escalated PR — is read at dispatch, off the PR’s current labels, not from an event.

  4. Connector constructs the envelope and emits event.

  5. ConnectorRegistry forwards it to the central handler in the harness.

  6. 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 and issue.labeled — the label just added. On a PR it is matched against review.requestLabel; on an issue against the configured autonomy.stages.*.enter labels 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 false for every event except issue.labeled, where it is the real answer and is read (see the invariant below) 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 / EventType from src/connectors/types.ts. There is no duplicate or platform-extended version.
  • labels is 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: true reaches the router on exactly one event type. The connector’s bot-sender filter drops bot-sent deliveries, with a deliberate narrow hole for issues.labeled — our own issue-triage applies ready-for-agent as the bot, and that chain has to fire. So on issue.labeled the field carries the real answer and is load-bearing: the build dispatch gate reads it for the already-built asymmetry (a bot re-applying the entry label is a hard skip; a human re-applying it is an explicit retry). On every other type it is still false, and code branching on senderIsBot === true elsewhere is still dead. See Router and Integrations.
  • type === "message" is the only chat-platform type. No Slack- specific subtypes (message.app_mention, message.dm). Disambiguation inside chat happens via fields in raw and by the router examining the body for @last-light mentions.
  • 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 EventType union, 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. The board’s cache invalidation (onBoardChanged) is the second instance of that pattern and the clearer one: it fires on issues / pull_request deliveries the envelope pipeline never sees — closed, unlabeled and deleted all sit in IGNORED_ACTIONS — and it asks for no dispatch at all, only a cache delete. Routing it as an event would have meant emitting issue.closed / pr.merged, which are declared in the union but which nothing emits, purely so a consumer could forget a cached answer. Unlike the other two callbacks it does not consume the delivery: the event still normalizes and dispatches underneath it.
  • Fields look optional but aren’t, for some events. A workflow that expects repo should refuse to run if envelope.repo is 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 raw as 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 type an 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 a reply(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.