Skip to content

Commit 4d109da

Browse files
authored
Merge pull request #13 from alicoding/goal/0027-composed-events
feat: trigger-system-event + composed forward (goal 0027, ADR-0035 build half)
2 parents 3b94db8 + de1b98c commit 4d109da

33 files changed

Lines changed: 1223 additions & 398 deletions

.claude/rules/architecture.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,30 @@ a named library — check before reaching for a `.map()` + custom markup
3939
or a hand-rolled parser (see `.claude/rules/frontend.md` for the
4040
concrete UI-collection instance of this).
4141

42+
**The core/composition boundary — before building ANY new capability,
43+
ask: is this a node, a trigger, a connector, or a true kernel
44+
change?** ([ADR-0035](../../docs/adr/0035-core-vs-composition-boundary.md),
45+
`docs/SPEC.md` §9.5's Update.) If a user could plausibly say "I want
46+
that, but to a different channel / with a condition / on a different
47+
event," it's composition-shaped and MUST arrive as composition — a
48+
self-registered `NodeType`, a trigger event, a Configure entity —
49+
never a bespoke service path plus a Settings toggle. Settings toggles
50+
configure the kernel; they never implement a side effect. Recorded
51+
counterexample, the reason this rule exists: cross-device notification
52+
shipped as a Settings checkbox wired to a private send path
53+
(`ForwardPendingApproval`) instead of a connector + trigger
54+
composition, caught live and refactored into a seeded, editable
55+
workflow. The flip side of the same rule: platform-internal behavior
56+
MAY and SHOULD consume Mill's own composition surface (a built-in,
57+
seeded, fully-editable workflow) rather than hand-rolling a parallel
58+
mini-pipeline for something the surface can already express — the app
59+
dogfooding its own platform, inspectable and guarded like anything a
60+
user builds. `docs/SPEC.md` §9.5 carries the protected-kernel list
61+
(graph engine, guardrail gate, durable execution, registries, the
62+
Configure recipe, the MCP plane) that composition never reaches into;
63+
changes there need an ADR, same bar this file's other architecture
64+
decisions already carry.
65+
4266
**Max 500 lines per hand-written source file (`.go`/`.ts`/`.tsx`).**
4367
Enforced by `scripts/check-loc.sh`, run by both Lefthook (pre-commit)
4468
and CI's `file-loc-limit` job, so it can't land un-caught either way. A

docs/SPEC.md

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1645,7 +1645,7 @@ Plan step for this as a standing rule.
16451645
|---|---|---|---|
16461646
| **Capture / Process / Apply** | Read structured state from a source, transform it, deliver it | Build (core domain) | `LOCKED`, §2 — built for clipboard/markdown |
16471647
| **Text injection** (a fixed hint/instruction pasted alongside a workflow's real output — e.g. telling an M365 Copilot chat what other tools are available) | Prepend or append configured static text to the payload | Build (core domain, `process-inject-text`, ADR-0006's self-registration pattern) — no templating engine; conditional injection composes for free with an upstream Decision node instead of adding branching logic to the node itself | `LOCKED`, built — `internal/domain/composition/processinjecttext.go`, e2e-verified (`composition-canvas-interactions.spec.ts`) end-to-end including via the generic ConfigField Inspector, no bespoke UI |
1648-
| **Trigger** | Entry-point node: listen for *any* event source (hotkey, clipboard change, a browser-bridge DOM event per §5, an incoming MCP `tools/call` per §3.1, a schedule) and emit its data as the workflow's starting input — not "the hotkey mechanism," a general category the hotkey is one instance of. A trigger's output *is* the workflow's input; these are one concept, not two. | Each concrete event source adopts its own library behind an adapter (hotkey/schedule/filesystem-watch do; clipboard-watch is a small build); the abstraction unifying them into one node kind, and `TriggerService`'s registry/exclusivity, are Mill's own | `LOCKED`, built (manual/hotkey/schedule/clipboard-watch/filesystem-watch) — see §3.4 for the fuller map. DOM-event and MCP-call triggers remain unbuilt, gated on §5/§3.1 |
1648+
| **Trigger** | Entry-point node: listen for *any* event source (hotkey, clipboard change, a browser-bridge DOM event per §5, an incoming MCP `tools/call` per §3.1, a schedule, Mill's own execution engine) and emit its data as the workflow's starting input — not "the hotkey mechanism," a general category the hotkey is one instance of. A trigger's output *is* the workflow's input; these are one concept, not two. | Each concrete event source adopts its own library behind an adapter (hotkey/schedule/filesystem-watch do; clipboard-watch is a small build); the abstraction unifying them into one node kind, and `TriggerService`'s registry/exclusivity, are Mill's own | `LOCKED`, built (manual/hotkey/schedule/clipboard-watch/filesystem-watch/callable/system-event) — see §3.4 for the fuller map, including `trigger-system-event`'s [ADR-0035](adr/0035-core-vs-composition-boundary.md) unparking. DOM-event and MCP-call triggers remain unbuilt, gated on §5/§3.1 |
16491649
| **Branch / routing** (UI-renamed from "Decision: route", ADR-0027) | Route execution down one of several named output edges based on a condition evaluated against the running payload | Node/graph semantics: build (core domain — composition rules). Expression evaluation underneath: adopt (`expr-lang/expr`, MIT, sandboxed/side-effect-free/loop-bounded by design — verified directly, not assumed) rather than hand-writing a condition parser | `LOCKED` (execution engine + authoring) — `internal/domain/composition`'s `ExecContext`/`ValidateGraph`/`nextNode` walk real branches end-to-end; `KindDecision` + `decision-route` NodeType render and connect on the canvas. Conditions are authored visually via a `react-querybuilder` rule builder (`DecisionEdgeInspector.tsx`), translated to `expr-lang/expr` — see §3.5's Branch row |
16501650
| **Decision (terminal outcome)** | Terminate a branch with a reusable, Configure-authored typed outcome: category + typed outputs + optional webhook; manual-review category parks into the Review queue first | Entity/CRUD/terminal-node semantics: build (core domain, the List/MCP-Server pattern). Webhook transport: reuse (the referenced HTTPRequest's own execution path via the extracted `httpsend.go` — never a second HTTP client). Park mechanism: reuse (the same `waitForApprovalFn` human-review uses) | `LOCKED`, built — [ADR-0027](adr/0027-decision-terminal-outcome.md): `internal/domain/decision`, `KindTerminal` + `decision-outcome` (no source handle, three-layer outgoing-edge rejection), Configure → Decisions tab, typed `outputBindings`, seeded branch-to-decision + manual-review examples proven against real DBOS, 96/96 e2e twice. Building it surfaced and fixed a real latent bug: the guardrail gate and dry-run tester read the *static* per-NodeType effect class, which would have hung a manual-review Decision run — generalized to `EffectForNode` (dynamic: a webhook-bearing Decision is `external`, a plain one `local`) and `NodeAlwaysParks` (human-review's hardcoded check, generalized). MCP write-tools for Decisions (`import_decision`/`export_decision`) are a named, mechanical follow-up — read Resources (`mill://decisions`) shipped |
16511651
| **Parallel Steps** | Fan out to multiple steps concurrently, then join | Graph/fan-in semantics: build. Concurrency execution: DBOS's `Queue`/`WithWorkerConcurrency` (§7) is a plausible real backing mechanism once designed, not hand-rolled goroutine management | ADR-0005 names it, deferred |
@@ -1809,10 +1809,12 @@ Zapier, Raycast — chosen because they're the platforms already anchoring
18091809
this design elsewhere in this doc) rather than invented from Mill's two
18101810
existing entry points (hotkey, manual click).
18111811

1812-
**Built**`KindTrigger`, five `NodeType`s, `TriggerService`, typed
1813-
`ConfigField`s, hotkey exclusivity, and payload generation are all
1814-
real code; the design reasoning below is accurate as originally
1815-
written, not a later correction.
1812+
**Built**`KindTrigger`, six `NodeType`s (a sixth, `trigger-system-event`,
1813+
added by [ADR-0035](adr/0035-core-vs-composition-boundary.md) — see the
1814+
System/meta row below), `TriggerService`, typed `ConfigField`s, hotkey
1815+
exclusivity, and payload generation are all real code; the design
1816+
reasoning below is accurate as originally written, not a later
1817+
correction.
18161818

18171819
**Grouping is by delivery mechanism, not business domain** — this is the
18181820
axis that actually determines config shape and the adopt-vs-build call
@@ -1842,7 +1844,7 @@ regular interval) or webhook/real-time (service pushes events instantly)"
18421844
| **Incoming MCP tool call** | C | An agent/chat client invokes one of Mill's exposed tools | Adopt (Go SDK's `Server.AddReceivingMiddleware`, already `LOCKED`, §3.1) | `OPEN` as a graph Trigger kind — validated as a real, established category (not a Mill invention) by n8n shipping its own dedicated MCP Server Trigger node |
18431845
| **Webhook / incoming HTTP** | C | External service POSTs an event to a Mill-owned endpoint | Not a library gap — Mill already runs an HTTP server in server-mode (Wails3 + stdlib `net/http`); the open question is purely whether Mill should run a public listener at all | `OPEN` — a scope/threat-model decision, not an adoption decision |
18441846
| **App/connector-specific** (e.g. email/IMAP) | B or C | Poll or push scoped to one external service | Depends on §4 Connectors | `PARKED` until §4 resolves — not a distinct Trigger *kind*, a connector-scoped instance of Group B/C |
1845-
| **System/meta** (run failed, workflow updated) | D | Fired by Mill's own execution engine | Build, depends on §7 | `PARKED` until §7's execution engine lands — direct analog to n8n's Error Trigger / Workflow Trigger |
1847+
| **System/meta** (decision-parked, run-completed/-failed/-cancelled) | D | Fired by Mill's own execution engine | Build (`trigger-system-event`, §7's engine) | `LOCKED`, built ([ADR-0035](adr/0035-core-vs-composition-boundary.md)) — direct analog to n8n's Error Trigger / Workflow Trigger, unparked once §7 landed. Config: `event` (options, one of the four above) + `workflowScope` (empty/"all", or one specific workflow's ID via the ADR-0009 picker, `RefKind: "workflow-scope"`). Fire payload (`InitialPayload`, JSON): `{event, runId, workflowId, workflowLabel, nodeId?, timestamp}` — `nodeId` only set for `decision-parked`. **Loop rule** (n8n's Error Trigger precedent, enforced at emission): a run whose OWN root trigger is `trigger-system-event` never emits a system event of its own, of ANY kind — a chain always bottoms out after one hop. Dispatch seam: `ExecutionService` exposes `SetSystemEventSink` (an injected-function seam, mirrors `SetConnectorLookup`); `TriggerService.DispatchSystemEvent` is wired in from `main.go`, keeping the import direction one-way (`executionsvc` never imports `triggersvc`). Emission sites: `parkForApproval` (decision-parked, `executionservice_guardrail.go`), `runWorkflow` (run-completed/run-failed, the one DBOS-registered function every run kind executes through), `CancelRun` (run-cancelled). First composed consumer: the seeded "Example: Forward pending approvals" workflow (§3.7's Update) — the forward-refactor proof. |
18461848
| **Callable by another workflow** | D | Fired only when a Child Workflow node (docs/adr/0010) invokes this workflow — never a real external event | Build (composition rule; execution rides on DBOS's native parent/child call, already adopted §7) | `LOCKED`, built — `trigger-callable` NodeType, no listener process (same shape as `trigger-manual`); direct analog to n8n's Execute Workflow Trigger |
18471849

18481850
**Architecture conclusion: each trigger type is its own `NodeType` under
@@ -2652,8 +2654,12 @@ Update has the full writeup. A menu-bar/dock *presence toggle* (hiding
26522654
the dock icon entirely) stays unbuilt — a different capability than the
26532655
badge — and **trigger-fire notifications remain a named future use of
26542656
the now-existing mechanism**, not built yet: the same `notify.SendPlain`/
2655-
`SendActionable` primitives this pass added would carry it, once a
2656-
concrete "fire on X" event is chosen.
2657+
`SendActionable` primitives this pass added would carry it. **Update
2658+
(ADR-0035): the concrete "fire on X" event this was blocked on now
2659+
exists**`trigger-system-event`'s four events — but wiring an
2660+
OS-notification NodeType (rather than `NotifyPendingApproval` staying
2661+
Settings-governed kernel chrome) is still unbuilt; the forward's own
2662+
HTTP path is the first composed consumer, not this one.
26572663

26582664
**Attention escalation — `LOCKED` and built (docs/goals/archive/0023-
26592665
attention-escalation.md, ADR-0032's Update).** The `document.hasFocus()`-only
@@ -2671,12 +2677,28 @@ write or "Open in Mill" for a guardrail park. **(3)** alert-style
26712677
authorization is now actually requested (`notify.Start` previously
26722678
never called `RequestNotificationAuthorization` at all); Settings
26732679
documents the System Settings → Notifications → Mill → Alerts toggle.
2674-
**(4)** a cross-device forward, `composition.SendJSONWebhook` (reuses
2675-
the integration-http/decision-outcome transport tail) +
2676-
`SettingsService.ForwardPendingApproval`, default-off, POSTs
2677-
`{kind, id, description, createdAt}` to a Settings-configured
2678-
HTTPRequest, independent of the presence gate — the layer that reaches
2679-
the owner with no local Mac to notify on at all.
2680+
**(4)** a cross-device forward — **Update ([ADR-0035](adr/0035-core-vs-composition-boundary.md),
2681+
2026-08-12): moved from a Settings toggle + private send path
2682+
(`SettingsService.ForwardPendingApproval`, `composition.SendJSONWebhook`
2683+
— both deleted) to composition**, the forward-refactor's own proof: a
2684+
seeded, DISABLED-by-default workflow, "Example: Forward pending
2685+
approvals" (`trigger-system-event(decision-parked)`
2686+
`integration-http` against the same seeded no-auth HTTPRequest
2687+
"Example: Approval-gated HTTP call" already uses, re-pointed by the
2688+
user at their real endpoint). `integration-http`'s body resolution
2689+
(`integration.go`) now falls back to `ctx.Payload` when neither the
2690+
node nor the integration configures one, so the trigger's own JSON
2691+
event becomes the POST body with zero templating needed. Same fail-safe
2692+
default as any other external-effect step (SPEC §8): parks awaiting
2693+
approval until the user adds a Configure > Guardrails allow rule
2694+
scoped to this one node, exactly like the guarded-HTTP example already
2695+
demonstrates. A migration note logs once at startup if a pre-refactor
2696+
`settings-forward-approvals-enabled` key is present, naming the
2697+
replacement — config is never silently dropped. The OS-notification
2698+
half (`NotifyPendingApproval`) is unchanged, staying a Settings-
2699+
governed kernel default (the away-user attention layer, §9.5's
2700+
protected-kernel list) — only the HTTP forward moved; full
2701+
notification-as-a-node is named future work, not built.
26802702

26812703
**Staleness presentation — `LOCKED` and built
26822704
(docs/goals/archive/0026-request-lifecycle-honesty.md), applying the §1
@@ -3944,6 +3966,27 @@ infrastructure. This inheritance list IS the working definition of
39443966
the Accessibility re-grant tax on every reinstall); Configure-
39453967
entity draft/live lifecycle (workflows have it; entities don't).
39463968

3969+
**Update ([ADR-0035](adr/0035-core-vs-composition-boundary.md),
3970+
2026-08-12): the core/composition BOUNDARY, sharpened.** This
3971+
section's extension contract said what a new capability brings/
3972+
inherits; ADR-0035 adds the decision test that determines whether a
3973+
capability should even reach for that contract vs. a true kernel
3974+
change: **is this a node, a trigger, a connector — or a true kernel
3975+
change?** If a user could plausibly say "I want that, but to a
3976+
different channel / with a condition / on a different event," it's
3977+
composition-shaped and MUST arrive as composition, never a bespoke
3978+
service path plus a Settings toggle. Recorded counterexample: cross-
3979+
device notification shipped hours after this section was first
3980+
written, AS a Settings toggle + private send path
3981+
(`ForwardPendingApproval`) — caught live, refactored into the seeded
3982+
"Example: Forward pending approvals" workflow (§3.7's Update). The
3983+
kernel list above stays the protected-kernel definition; ADR-0035's
3984+
second contract is new: platform-internal behavior MAY and SHOULD
3985+
consume the same composition surface as built-in, seeded, editable
3986+
workflows (the app dogfooding its own platform) — what the platform
3987+
never does is hand-roll a parallel mini-pipeline for something the
3988+
surface can already express.
3989+
39473990
## 10. Open questions log
39483991

39493992
- Decision as a reusable typed terminal outcome (§3.3/§3.5) —

0 commit comments

Comments
 (0)