@exponential_app/engine 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +54 -0
  3. package/README.md +35 -2
  4. package/dist/capabilities.js +29 -0
  5. package/dist/capabilities.js.map +9 -0
  6. package/dist/chunks/index-4ntgtkmb.js +6783 -0
  7. package/dist/chunks/index-4ntgtkmb.js.map +13 -0
  8. package/dist/chunks/index-ans80t68.js +382 -0
  9. package/dist/chunks/index-ans80t68.js.map +9 -0
  10. package/dist/chunks/index-ax4aay30.js +315 -0
  11. package/dist/chunks/index-ax4aay30.js.map +10 -0
  12. package/dist/chunks/index-e49v2qpf.js +2177 -0
  13. package/dist/chunks/index-e49v2qpf.js.map +9 -0
  14. package/dist/chunks/index-m2845dn6.js +72 -0
  15. package/dist/chunks/index-m2845dn6.js.map +10 -0
  16. package/dist/chunks/index-md3hz6r2.js +3015 -0
  17. package/dist/chunks/index-md3hz6r2.js.map +19 -0
  18. package/dist/chunks/index-n0acffvs.js +366 -0
  19. package/dist/chunks/index-n0acffvs.js.map +10 -0
  20. package/dist/chunks/index-nrr8x2kq.js +259 -0
  21. package/dist/chunks/index-nrr8x2kq.js.map +10 -0
  22. package/dist/chunks/index-s3xjr701.js +170 -0
  23. package/dist/chunks/index-s3xjr701.js.map +10 -0
  24. package/dist/chunks/index-t89cvabm.js +2301 -0
  25. package/dist/chunks/index-t89cvabm.js.map +10 -0
  26. package/dist/chunks/index-zw0575tv.js +126 -0
  27. package/dist/chunks/index-zw0575tv.js.map +10 -0
  28. package/dist/consent.js +24 -0
  29. package/dist/consent.js.map +9 -0
  30. package/dist/index.js +526 -0
  31. package/dist/index.js.map +11 -0
  32. package/dist/lifecycle.js +11 -0
  33. package/dist/lifecycle.js.map +9 -0
  34. package/dist/outbox.js +27 -0
  35. package/dist/outbox.js.map +9 -0
  36. package/dist/pdf.js +15 -0
  37. package/dist/pdf.js.map +9 -0
  38. package/dist/providers.js +26 -0
  39. package/dist/providers.js.map +9 -0
  40. package/dist/runner.js +24 -0
  41. package/dist/runner.js.map +9 -0
  42. package/dist/tools.js +38 -0
  43. package/dist/tools.js.map +9 -0
  44. package/dist/types/domain-contract/src/app-records.d.ts +3148 -0
  45. package/dist/types/domain-contract/src/app-share.d.ts +222 -0
  46. package/dist/types/domain-contract/src/apps.d.ts +594 -0
  47. package/dist/types/eengine/fixtures/providers.json +55 -0
  48. package/dist/types/eengine/src/broker.d.ts +80 -0
  49. package/dist/types/eengine/src/capabilities.d.ts +86 -0
  50. package/dist/types/eengine/src/consent.d.ts +50 -0
  51. package/dist/types/eengine/src/index.d.ts +11 -0
  52. package/dist/types/eengine/src/instance.d.ts +122 -0
  53. package/dist/types/eengine/src/lifecycle.d.ts +64 -0
  54. package/dist/types/eengine/src/outbox.d.ts +3 -0
  55. package/dist/types/eengine/src/pdf.d.ts +36 -0
  56. package/dist/types/eengine/src/providers.d.ts +38 -0
  57. package/dist/types/eengine/src/runner.d.ts +82 -0
  58. package/dist/types/eengine/src/stores.d.ts +125 -0
  59. package/dist/types/eengine/src/tools.d.ts +79 -0
  60. package/dist/types/eengine/src/web/index.d.ts +20 -0
  61. package/dist/types/eengine/src/web/mcp.d.ts +27 -0
  62. package/dist/types/eengine/src/web/platform.d.ts +91 -0
  63. package/dist/types/eengine/src/web/stores.d.ts +125 -0
  64. package/dist/types/emcp/fixtures/automations.json +521 -0
  65. package/dist/types/emcp/fixtures/hub.json +498 -0
  66. package/dist/types/emcp/src/access.d.ts +55 -0
  67. package/dist/types/emcp/src/audit.d.ts +22 -0
  68. package/dist/types/emcp/src/automations.d.ts +111 -0
  69. package/dist/types/emcp/src/exposes.d.ts +242 -0
  70. package/dist/types/emcp/src/hub.d.ts +63 -0
  71. package/dist/types/emcp/src/index.d.ts +7 -0
  72. package/dist/types/emcp/src/pure.d.ts +11 -0
  73. package/dist/types/emcp/src/upstream.d.ts +288 -0
  74. package/dist/types/emcp/src/vault.d.ts +34 -0
  75. package/dist/web/index.js +791 -0
  76. package/dist/web/index.js.map +13 -0
  77. package/fixtures/bundle.json +1165 -0
  78. package/fixtures/capabilities.json +373 -0
  79. package/fixtures/consent.json +146 -0
  80. package/fixtures/lifecycle.json +125 -0
  81. package/fixtures/outbox.json +8 -0
  82. package/fixtures/providers.json +55 -0
  83. package/fixtures/runner.json +2167 -0
  84. package/package.json +92 -4
@@ -0,0 +1,521 @@
1
+ {
2
+ "$comment": "#VAPP-220 (eApps A1, docs/spec/apps.md §7.1, decisions §18.2/§18.4): AN APP'S AUTOMATIONS. A package lists them in `automations: [{name, on, run, sponsor?}]`; the SERVER BROKER runs them on the PUBLISHED package only (V1), unattended, as the automation's SPONSOR. ONE TRIGGER RUNS ONE BINDING: no chains, no branches, no loops; anything with judgement goes to an agent (A2). THE FORMAT: `name` matches `limits.namePattern`, at most `limits.nameMax`, unique in the app; at most `limits.maxAutomations` automations (more = `{index: null, code: tooMany}` first; not a list = `{index: null, code: list}` alone). `on` holds EXACTLY ONE of `triggerKinds` (else `on`, which stops that automation's checks): `schedule` = `{cron, tz?}`: a 5-field cron (`minute hour day-of-month month day-of-week`, the agents' trigger cron: `*`, lists, ranges, steps, month and weekday names), read in `tz` (an IANA zone) or else the TEAM time zone; a cron that is not 5 fields of `cronFieldPattern`, a minute or hour field that does not parse (0-59, 0-23) or a blank `tz` = `schedule`; the minutes of a day it fires on (minute × hour, circular over the day) closer than `limits.minScheduleGapMinutes` apart = `scheduleTooOften`. `record` = `{kind, op}`: `kind` one the manifest declares (else `kind`), `op` one of `recordTriggerOps` (else `on`). `event` = a team event NAME (the agents' event triggers' stream: the server's `issue.created`, `pr.merged`, `session.ended`… or any `events_emit` name) matching `limits.eventPattern`, at most `limits.eventMax` (else `event`). `sponsor` (optional) = a member's email (a string holding `@`, else `sponsor`). `run` = an `exposes`-style run (exposes.json: exactly one of `records`, `action`, `upstream`, the same codes: `run`, `kind`, `action`, `destructive`, `ownerOnly`, `upstream`); an action reaching an app (`exponential_apps_*`) = `chain` (an automation never reaches an app, its own included, through the apps noun). TEMPLATES: every string of `run` may hold `${record.<path>}` (a record trigger only: the record's data + `id`; on delete, the data it had), `${event.<path>}` (an event trigger only: the event's payload + `name`) and `${firedAt}` (every trigger: the run's ISO instant); any other `${…}`, or a root the trigger does not carry, = `template`. Resolution = exposes.json's `resolveArgs` over the input `{record?, event?, firedAt}` after `toInputTemplates` rewrites each root to `${input.<root>…}` (`resolveCases`). VALIDATION = `validateAutomations(list, {kinds, upstreams})` → every `{index, code}` in order (`validateCases`, codes = `errors`). MATCHING (`matchCases`): a record write or an event names the automations it fires, in package order: a record trigger whose kind and op equal the write's, an event trigger whose name equals the event's; a schedule is never matched here (the watchdog tick finds the latest due minute since the last check, the agents' `latestCronMatch`, caught up once after downtime, never twice). LOOP GUARD: a write or event caused BY an automation run (`fromAutomation: true`: the run's records write, the events its action emits) fires NOTHING, so an automation never triggers another one or itself. SPONSOR (C23, `sponsorCases`): `resolveSponsor({declared, publishedBy, members})` where `members` = the team's current members `{id, email, role, level}` (`level` = the member's app access, Settings › MCP Access: `none` | `read` | `write`). The publisher = the member whose id = `publishedBy` (the member who published the running version). No `declared` sponsor → the sponsor is the publisher. A `declared` email naming someone other than the publisher is honoured only when the publisher is a team `owner` (an owner may delegate; a member never makes an automation act as someone else) → else `sponsorNotAllowed`. A sponsor who is not (or no longer) a member, or no publisher at all = `sponsorGone`; a sponsor whose level is not `write` = `sponsorReadOnly`. Any of those PAUSES the automation (it does not run; the app's owners get ONE notification per pause) until a publish resolves it. RATE: at most `limits.runsPerMinute` runs per app per rolling minute (the app's `automation:*` audit rows of the last 60 s); a run over the cap is not started and is audited `rateLimited` (`rateCases`). AUDIT (`auditCases`): every run (and every refusal) = one `mcp_calls` row (audit.json) with `user_id` = the sponsor (a paused run: the publisher, else the app's creator), `app_id` = the app, `client_id` = `clientPrefix` + name, `tool` = `automationTool(run)`: `records.<op>:<kind>`, the action name, or `<upstream>/<tool>`; its writes read \"via <app>\" (§9.3). The app's Settings › Automations lists each automation with its trigger, its newest audit row (last run, last failure) and the runs dropped in the last 24 h (#VAPP-255, its `rateLimited` rows; a dropped run is not retried), from those rows. AGENT HAND-OFF (#VAPP-236, A2, spec §7.3): `run` may be exposes.json's `agent` form `{agent, prompt, device?}` (the same checks and codes: `agent`, `prompt`, `run`); its `prompt` holds the trigger's templates (`${record.<path>}`, `${event.<path>}`, `${firedAt}`, checked like every run string) and the server renders exposes.json's `agentBrief` over the input `{record?, event?, firedAt}` (after `toInputTemplates`), `source` = `automation <name>`, `ref` = `{kind, id}` of a record trigger's record; it runs as the SPONSOR through `sessions.send {agentId}` (the sponsor's conversation with that agent), the session reading `via <app>`, under exposes.json's hand-off cap as well as `limits.runsPerMinute`; its audit `tool` = `agent:<slug>` (`auditCases`). AGENT LOOP GUARD (`matchCases` with `byAgent`): a record write or an event caused from inside an AGENT's session tree (the acting agent of the causing session's root: its conversation agent, else the agent that started it) carries that agent's slug as `byAgent`; it never fires an automation whose run hands off to THAT agent, so an agent an automation woke never re-triggers it (nor any other hand-off to itself); every other automation still fires. INBOUND WEBHOOKS (#VAPP-237, A3, spec §7.2, §12.3, decision C22): `on: {webhook: {name}}` = the server's `POST` at `webhook.path` (`{appId}` = the app's id, `{name}` = the hook name). `name` matches `limits.namePattern`, at most `limits.nameMax`, unique among the app's webhook automations (else `webhook`); templates read `${event.<path>}` and `${firedAt}` (`templateRoots.webhook`), the event = `{hook: <name>, body: <the parsed JSON body>}`, so `${event.body.data.customer}` (`webhookEvent`). A delivery MATCHES (`matchCases` with `{webhook: name}`) the automations on that hook name. THE SECRET: one per hook (app + name), `webhook.secretPrefix` + base64url (no padding) of `webhook.secretBytes` random bytes, minted by a team OWNER on the app's Settings (shown ONCE, copyable; minting again = ROTATE: the old one stops at once), stored only vault-sealed (vault.json `ev1`, slot `app_hooks.secret_ciphertext.<row id>`), never synced, re-sealed by the vault sweep. A hook without a secret refuses every delivery (`unknownHook`). THE SIGNATURE (`signatureCases`): header `webhook.header` = `t=<unix seconds>,v1=<lowercase hex HMAC-SHA256(secret, \"<t>.\" + the raw body bytes as UTF-8)>` (`webhookSignature`; several `v1=` may ride along, any one matching counts; whitespace around parts is ignored; another key is ignored). VERIFICATION (`verifyCases`, `verifyWebhook({header, body, bytes, secret, now, seen})`), in this order, the first failure answers: the body over `webhook.bodyMax` bytes = `tooLarge`; no header = `missingSignature`; a header without exactly one `t` (1-12 digits) or without a `v1` of 64 lowercase hex characters = `badSignature`; `|now - t|` over `webhook.toleranceSeconds` = `stale`; no `v1` equal (constant time) to the HMAC = `badSignature`; the matching `v1` (the NONCE) already in the hook's nonce cache (`seen`: every accepted nonce of the last 2 × `toleranceSeconds`) = `replayed`; a body that is not JSON = `notJson`; else `{ok, nonce, body}` (the body parsed only after the signature verified). Each refusal answers its `webhook.httpStatus` (a verified delivery: 202 with the run's status). RATE: at most `webhook.callsPerMinute` deliveries per hook per rolling minute (its audit rows), over it = 429 `rateLimited`, NOT audited (a flood never fills the audit table). AUDIT: every delivery under the cap = one `mcp_calls` row `client_id = automation:<name of each matched automation>` like every run; a refused one (signature, window, replay, size, JSON) is audited `denied` with the code as `error` under the hook's first automation, as the publisher (else the app's creator). The run itself is A1's (`runAutomation`: the sponsor, the app cap, the published package).",
3
+ "limits": {
4
+ "namePattern": "^[a-z][a-z0-9_]*$",
5
+ "nameMax": 40,
6
+ "maxAutomations": 16,
7
+ "minScheduleGapMinutes": 15,
8
+ "eventPattern": "^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)*$",
9
+ "eventMax": 128,
10
+ "runsPerMinute": 30,
11
+ "clientPrefix": "automation:"
12
+ },
13
+ "triggerKinds": ["schedule", "record", "event", "webhook"],
14
+ "recordTriggerOps": ["create", "update", "delete"],
15
+ "cronFieldPattern": "^[A-Za-z0-9*,/-]+$",
16
+ "templateRoots": { "schedule": ["firedAt"], "record": ["record", "firedAt"], "event": ["event", "firedAt"], "webhook": ["event", "firedAt"] },
17
+ "sponsorStates": ["sponsorGone", "sponsorReadOnly", "sponsorNotAllowed"],
18
+ "errors": ["list", "tooMany", "shape", "name", "duplicate", "on", "schedule", "scheduleTooOften", "kind", "event", "sponsor", "template", "chain", "run", "action", "destructive", "ownerOnly", "upstream", "agent", "prompt", "webhook"],
19
+ "validateCases": [
20
+ {
21
+ "name": "the spec's three automations are valid",
22
+ "context": { "kinds": ["rotation", "request"], "upstreams": ["stripe"] },
23
+ "automations": [
24
+ { "name": "monday_rotation", "on": { "schedule": { "cron": "0 8 * * MON" } }, "run": { "records": { "op": "upsert", "kind": "rotation", "data": { "at": "${firedAt}" } } } },
25
+ { "name": "notify_approver", "on": { "record": { "kind": "request", "op": "create" } }, "run": { "action": "exponential_comments_create", "args": { "userId": "${record.approver}", "text": "${record.title}" } } },
26
+ { "name": "merged", "on": { "event": "pr.merged" }, "sponsor": "lead@acme.test", "run": { "upstream": "stripe", "tool": "create_note", "args": { "text": "${event.title}" } } }
27
+ ],
28
+ "expected": []
29
+ },
30
+ {
31
+ "name": "a schedule in its own zone, every quarter hour",
32
+ "context": { "kinds": [], "upstreams": [] },
33
+ "automations": [{ "name": "tick", "on": { "schedule": { "cron": "*/15 9-17 * * mon-fri", "tz": "Europe/Vienna" } }, "run": { "action": "exponential_comments_create", "args": { "text": "tick" } } }],
34
+ "expected": []
35
+ },
36
+ {
37
+ "name": "not a list",
38
+ "context": { "kinds": [], "upstreams": [] },
39
+ "automations": { "name": "x" },
40
+ "expected": [{ "index": null, "code": "list" }]
41
+ },
42
+ {
43
+ "name": "names: pattern, duplicate, not an object",
44
+ "context": { "kinds": ["a"], "upstreams": [] },
45
+ "automations": [
46
+ { "name": "Bad-Name", "on": { "record": { "kind": "a", "op": "create" } }, "run": { "records": { "op": "upsert", "kind": "a", "data": {} } } },
47
+ { "name": "ok", "on": { "record": { "kind": "a", "op": "create" } }, "run": { "records": { "op": "upsert", "kind": "a", "data": {} } } },
48
+ { "name": "ok", "on": { "record": { "kind": "a", "op": "update" } }, "run": { "records": { "op": "upsert", "kind": "a", "data": {} } } },
49
+ "nope"
50
+ ],
51
+ "expected": [
52
+ { "index": 0, "code": "name" },
53
+ { "index": 2, "code": "duplicate" },
54
+ { "index": 3, "code": "shape" }
55
+ ]
56
+ },
57
+ {
58
+ "name": "on: none, two, an unknown record op",
59
+ "context": { "kinds": ["a"], "upstreams": [] },
60
+ "automations": [
61
+ { "name": "none", "on": {}, "run": { "records": { "op": "upsert", "kind": "a", "data": {} } } },
62
+ { "name": "two", "on": { "event": "pr.merged", "record": { "kind": "a", "op": "create" } }, "run": { "records": { "op": "upsert", "kind": "a", "data": {} } } },
63
+ { "name": "op", "on": { "record": { "kind": "a", "op": "upsert" } }, "run": { "records": { "op": "upsert", "kind": "a", "data": {} } } }
64
+ ],
65
+ "expected": [
66
+ { "index": 0, "code": "on" },
67
+ { "index": 1, "code": "on" },
68
+ { "index": 2, "code": "on" }
69
+ ]
70
+ },
71
+ {
72
+ "name": "schedules: four fields, a bad hour, a blank zone, every minute, every five minutes, two close minutes across midnight",
73
+ "context": { "kinds": [], "upstreams": [] },
74
+ "automations": [
75
+ { "name": "s1", "on": { "schedule": { "cron": "0 8 * *" } }, "run": { "action": "exponential_comments_create" } },
76
+ { "name": "s2", "on": { "schedule": { "cron": "0 25 * * *" } }, "run": { "action": "exponential_comments_create" } },
77
+ { "name": "s3", "on": { "schedule": { "cron": "0 8 * * *", "tz": " " } }, "run": { "action": "exponential_comments_create" } },
78
+ { "name": "s4", "on": { "schedule": { "cron": "* * * * *" } }, "run": { "action": "exponential_comments_create" } },
79
+ { "name": "s5", "on": { "schedule": { "cron": "*/5 8 * * *" } }, "run": { "action": "exponential_comments_create" } },
80
+ { "name": "s6", "on": { "schedule": { "cron": "5,55 0,23 * * *" } }, "run": { "action": "exponential_comments_create" } },
81
+ { "name": "s7", "on": { "schedule": "0 8 * * *" }, "run": { "action": "exponential_comments_create" } }
82
+ ],
83
+ "expected": [
84
+ { "index": 0, "code": "schedule" },
85
+ { "index": 1, "code": "schedule" },
86
+ { "index": 2, "code": "schedule" },
87
+ { "index": 3, "code": "scheduleTooOften" },
88
+ { "index": 4, "code": "scheduleTooOften" },
89
+ { "index": 5, "code": "scheduleTooOften" },
90
+ { "index": 6, "code": "schedule" }
91
+ ]
92
+ },
93
+ {
94
+ "name": "an undeclared trigger kind, a bad event name, a sponsor that is no email",
95
+ "context": { "kinds": ["a"], "upstreams": [] },
96
+ "automations": [
97
+ { "name": "k", "on": { "record": { "kind": "b", "op": "create" } }, "run": { "records": { "op": "upsert", "kind": "a", "data": {} } } },
98
+ { "name": "e", "on": { "event": "PR Merged" }, "run": { "records": { "op": "upsert", "kind": "a", "data": {} } } },
99
+ { "name": "p", "on": { "event": "pr.merged" }, "sponsor": "danny", "run": { "records": { "op": "upsert", "kind": "a", "data": {} } } }
100
+ ],
101
+ "expected": [
102
+ { "index": 0, "code": "kind" },
103
+ { "index": 1, "code": "event" },
104
+ { "index": 2, "code": "sponsor" }
105
+ ]
106
+ },
107
+ {
108
+ "name": "templates: a root the trigger lacks, an unknown root, input",
109
+ "context": { "kinds": ["a"], "upstreams": [] },
110
+ "automations": [
111
+ { "name": "t1", "on": { "schedule": { "cron": "0 8 * * *" } }, "run": { "records": { "op": "upsert", "kind": "a", "data": { "x": "${record.x}" } } } },
112
+ { "name": "t2", "on": { "event": "pr.merged" }, "run": { "records": { "op": "upsert", "kind": "a", "data": { "x": "${now}" } } } },
113
+ { "name": "t3", "on": { "record": { "kind": "a", "op": "create" } }, "run": { "records": { "op": "upsert", "kind": "a", "data": { "x": "by ${input.x}" } } } },
114
+ { "name": "t4", "on": { "record": { "kind": "a", "op": "delete" } }, "run": { "records": { "op": "upsert", "kind": "a", "data": { "was": "${record.id}", "at": "${firedAt}" } } } }
115
+ ],
116
+ "expected": [
117
+ { "index": 0, "code": "template" },
118
+ { "index": 1, "code": "template" },
119
+ { "index": 2, "code": "template" }
120
+ ]
121
+ },
122
+ {
123
+ "name": "runs: the exposes codes, and no chaining through the apps noun",
124
+ "context": { "kinds": ["a"], "upstreams": ["stripe"] },
125
+ "automations": [
126
+ { "name": "r1", "on": { "event": "x" }, "run": { "records": { "op": "delete", "kind": "a", "id": "1" } } },
127
+ { "name": "r2", "on": { "event": "x" }, "run": { "records": { "op": "upsert", "kind": "zz", "data": {} } } },
128
+ { "name": "r3", "on": { "event": "x" }, "run": { "action": "exponential_issues_delete", "args": { "id": "${event.issueId}" } } },
129
+ { "name": "r4", "on": { "event": "x" }, "run": { "action": "exponential_apps_records_upsert", "args": {} } },
130
+ { "name": "r5", "on": { "event": "x" }, "run": { "upstream": "github", "tool": "comment" } },
131
+ { "name": "r6", "on": { "event": "x" }, "run": { "records": { "op": "upsert", "kind": "a" }, "action": "exponential_issues_create" } },
132
+ { "name": "r7", "on": { "event": "x" }, "run": { "action": "nope" } }
133
+ ],
134
+ "expected": [
135
+ { "index": 0, "code": "destructive" },
136
+ { "index": 1, "code": "kind" },
137
+ { "index": 2, "code": "destructive" },
138
+ { "index": 3, "code": "chain" },
139
+ { "index": 4, "code": "upstream" },
140
+ { "index": 5, "code": "run" },
141
+ { "index": 6, "code": "action" }
142
+ ]
143
+ },
144
+ {"name": "an agent hand-off on a record create, and its refusals", "context": {"kinds": ["request"], "upstreams": []}, "automations": [{"name": "route_request", "on": {"record": {"kind": "request", "op": "create"}}, "run": {"agent": "triage", "prompt": "Route the request ${record.title} (filed ${firedAt})."}}, {"name": "bad_agent", "on": {"event": "pr.merged"}, "run": {"agent": "Triage", "prompt": "", "device": "phone"}}, {"name": "bad_root", "on": {"schedule": {"cron": "0 9 * * *"}}, "run": {"agent": "triage", "prompt": "Daily: ${record.title}"}}], "expected": [{"index": 1, "code": "agent"}, {"index": 1, "code": "prompt"}, {"index": 1, "code": "run"}, {"index": 2, "code": "template"}]},
145
+ {"name": "webhooks: a valid hook, a bad name, a hook used twice, a root it does not carry", "context": {"kinds": ["payment"], "upstreams": []}, "automations": [{"name": "stripe_paid", "on": {"webhook": {"name": "stripe"}}, "run": {"records": {"op": "upsert", "kind": "payment", "data": {"customer": "${event.body.data.customer}", "at": "${firedAt}"}}}}, {"name": "bad_hook", "on": {"webhook": {"name": "Stripe-Hook"}}, "run": {"records": {"op": "upsert", "kind": "payment", "data": {}}}}, {"name": "twice", "on": {"webhook": {"name": "stripe"}}, "run": {"records": {"op": "upsert", "kind": "payment", "data": {}}}}, {"name": "no_record", "on": {"webhook": {"name": "github"}}, "run": {"records": {"op": "upsert", "kind": "payment", "data": {"x": "${record.id}"}}}}, {"name": "not_object", "on": {"webhook": "stripe"}, "run": {"records": {"op": "upsert", "kind": "payment", "data": {}}}}], "expected": [{"index": 1, "code": "webhook"}, {"index": 2, "code": "webhook"}, {"index": 3, "code": "template"}, {"index": 4, "code": "webhook"}]}
146
+ ],
147
+ "tooManyCase": { "count": 17, "expected": [{ "index": null, "code": "tooMany" }] },
148
+ "matchCases": [
149
+ {
150
+ "name": "a record create fires the create automations of its kind, in order",
151
+ "automations": [
152
+ { "name": "a", "on": { "record": { "kind": "request", "op": "create" } } },
153
+ { "name": "b", "on": { "record": { "kind": "request", "op": "update" } } },
154
+ { "name": "c", "on": { "record": { "kind": "other", "op": "create" } } },
155
+ { "name": "d", "on": { "event": "request.create" } },
156
+ { "name": "e", "on": { "schedule": { "cron": "0 8 * * *" } } },
157
+ { "name": "f", "on": { "record": { "kind": "request", "op": "create" } } }
158
+ ],
159
+ "trigger": { "record": { "kind": "request", "op": "create" } },
160
+ "fromAutomation": false,
161
+ "expected": ["a", "f"]
162
+ },
163
+ {
164
+ "name": "an event fires the automations on its name",
165
+ "automations": [
166
+ { "name": "a", "on": { "event": "pr.merged" } },
167
+ { "name": "b", "on": { "event": "pr.opened" } },
168
+ { "name": "c", "on": { "record": { "kind": "pr", "op": "create" } } }
169
+ ],
170
+ "trigger": { "event": "pr.merged" },
171
+ "fromAutomation": false,
172
+ "expected": ["a"]
173
+ },
174
+ {
175
+ "name": "LOOP GUARD: an automation's own write fires nothing (not even itself)",
176
+ "automations": [{ "name": "a", "on": { "record": { "kind": "request", "op": "create" } } }],
177
+ "trigger": { "record": { "kind": "request", "op": "create" } },
178
+ "fromAutomation": true,
179
+ "expected": []
180
+ },
181
+ {
182
+ "name": "LOOP GUARD: an event an automation's action emitted fires nothing",
183
+ "automations": [{ "name": "a", "on": { "event": "issue.created" } }],
184
+ "trigger": { "event": "issue.created" },
185
+ "fromAutomation": true,
186
+ "expected": []
187
+ },
188
+ {
189
+ "name": "a malformed entry is skipped, never fired",
190
+ "automations": [{ "name": "a" }, "x", { "name": "b", "on": { "event": "pr.merged" } }],
191
+ "trigger": { "event": "pr.merged" },
192
+ "fromAutomation": false,
193
+ "expected": ["b"]
194
+ },
195
+ {"name": "AGENT LOOP GUARD: an agent's own write never fires a hand-off to it", "automations": [{"name": "a", "on": {"record": {"kind": "request", "op": "update"}}, "run": {"agent": "triage", "prompt": "x"}}, {"name": "b", "on": {"record": {"kind": "request", "op": "update"}}, "run": {"agent": "review", "prompt": "x"}}, {"name": "c", "on": {"record": {"kind": "request", "op": "update"}}, "run": {"records": {"op": "upsert", "kind": "log", "data": {}}}}], "trigger": {"record": {"kind": "request", "op": "update"}, "byAgent": "triage"}, "fromAutomation": false, "expected": ["b", "c"]},
196
+ {"name": "AGENT LOOP GUARD: an event from an agent's tree, and a person's write fires all", "automations": [{"name": "a", "on": {"event": "issue.created"}, "run": {"agent": "triage", "prompt": "x"}}, {"name": "b", "on": {"event": "issue.created"}, "run": {"agent": "review", "prompt": "x"}}], "trigger": {"event": "issue.created", "byAgent": "triage"}, "fromAutomation": false, "expected": ["b"]},
197
+ {"name": "no acting agent: every hand-off fires", "automations": [{"name": "a", "on": {"event": "issue.created"}, "run": {"agent": "triage", "prompt": "x"}}], "trigger": {"event": "issue.created", "byAgent": null}, "fromAutomation": false, "expected": ["a"]},
198
+ {"name": "a webhook delivery fires the automations on its hook", "automations": [{"name": "a", "on": {"webhook": {"name": "stripe"}}}, {"name": "b", "on": {"webhook": {"name": "github"}}}, {"name": "c", "on": {"event": "stripe"}}], "trigger": {"webhook": "stripe"}, "fromAutomation": false, "expected": ["a"]}
199
+ ],
200
+ "sponsorCases": [
201
+ {
202
+ "name": "no declared sponsor: the publisher",
203
+ "input": { "declared": null, "publishedBy": "u1", "members": [{ "id": "u1", "email": "ann@acme.test", "role": "member", "level": "write" }] },
204
+ "expected": { "userId": "u1" }
205
+ },
206
+ {
207
+ "name": "the publisher naming themself",
208
+ "input": { "declared": "ann@acme.test", "publishedBy": "u1", "members": [{ "id": "u1", "email": "ann@acme.test", "role": "member", "level": "write" }] },
209
+ "expected": { "userId": "u1" }
210
+ },
211
+ {
212
+ "name": "an owner delegates to another member (emails compare case-blind)",
213
+ "input": {
214
+ "declared": "Bob@Acme.test",
215
+ "publishedBy": "u1",
216
+ "members": [
217
+ { "id": "u1", "email": "ann@acme.test", "role": "owner", "level": "write" },
218
+ { "id": "u2", "email": "bob@acme.test", "role": "member", "level": "write" }
219
+ ]
220
+ },
221
+ "expected": { "userId": "u2" }
222
+ },
223
+ {
224
+ "name": "a member never makes an automation act as someone else",
225
+ "input": {
226
+ "declared": "bob@acme.test",
227
+ "publishedBy": "u1",
228
+ "members": [
229
+ { "id": "u1", "email": "ann@acme.test", "role": "member", "level": "write" },
230
+ { "id": "u2", "email": "bob@acme.test", "role": "owner", "level": "write" }
231
+ ]
232
+ },
233
+ "expected": { "paused": "sponsorNotAllowed" }
234
+ },
235
+ {
236
+ "name": "the publisher left the team",
237
+ "input": { "declared": null, "publishedBy": "u1", "members": [{ "id": "u2", "email": "bob@acme.test", "role": "owner", "level": "write" }] },
238
+ "expected": { "paused": "sponsorGone" }
239
+ },
240
+ {
241
+ "name": "nobody published (a deleted account)",
242
+ "input": { "declared": null, "publishedBy": null, "members": [{ "id": "u2", "email": "bob@acme.test", "role": "owner", "level": "write" }] },
243
+ "expected": { "paused": "sponsorGone" }
244
+ },
245
+ {
246
+ "name": "the named sponsor is no member",
247
+ "input": { "declared": "eve@acme.test", "publishedBy": "u1", "members": [{ "id": "u1", "email": "ann@acme.test", "role": "owner", "level": "write" }] },
248
+ "expected": { "paused": "sponsorGone" }
249
+ },
250
+ {
251
+ "name": "the sponsor's app access is read",
252
+ "input": { "declared": null, "publishedBy": "u1", "members": [{ "id": "u1", "email": "ann@acme.test", "role": "member", "level": "read" }] },
253
+ "expected": { "paused": "sponsorReadOnly" }
254
+ },
255
+ {
256
+ "name": "the delegated sponsor's app access is none",
257
+ "input": {
258
+ "declared": "bob@acme.test",
259
+ "publishedBy": "u1",
260
+ "members": [
261
+ { "id": "u1", "email": "ann@acme.test", "role": "owner", "level": "write" },
262
+ { "id": "u2", "email": "bob@acme.test", "role": "member", "level": "none" }
263
+ ]
264
+ },
265
+ "expected": { "paused": "sponsorReadOnly" }
266
+ }
267
+ ],
268
+ "rateCases": [
269
+ { "runsLastMinute": 0, "allowed": true },
270
+ { "runsLastMinute": 29, "allowed": true },
271
+ { "runsLastMinute": 30, "allowed": false },
272
+ { "runsLastMinute": 31, "allowed": false }
273
+ ],
274
+ "resolveCases": [
275
+ {
276
+ "name": "a record's fields keep their type when whole, become text inside text",
277
+ "run": { "action": "exponential_comments_create", "args": { "userId": "${record.approver}", "text": "New request: ${record.title} (${record.hours} h)", "hours": "${record.hours}" } },
278
+ "input": { "record": { "id": "r1", "approver": "u2", "title": "Leave", "hours": 8 }, "firedAt": "2026-11-03T08:00:00.000Z" },
279
+ "expected": { "action": "exponential_comments_create", "args": { "userId": "u2", "text": "New request: Leave (8 h)", "hours": 8 } }
280
+ },
281
+ {
282
+ "name": "an event's payload, the instant, a missing field dropped",
283
+ "run": { "records": { "op": "upsert", "kind": "log", "data": { "pr": "${event.number}", "name": "${event.name}", "at": "${firedAt}", "gone": "${event.nope}" } } },
284
+ "input": { "event": { "name": "pr.merged", "number": 42 }, "firedAt": "2026-11-03T08:00:00.000Z" },
285
+ "expected": { "records": { "op": "upsert", "kind": "log", "data": { "pr": 42, "name": "pr.merged", "at": "2026-11-03T08:00:00.000Z" } } }
286
+ }
287
+ ],
288
+ "webhook": {
289
+ "path": "/api/apps/hooks/{appId}/{name}",
290
+ "header": "exponential-signature",
291
+ "secretPrefix": "whsec_",
292
+ "secretBytes": 32,
293
+ "toleranceSeconds": 300,
294
+ "bodyMax": 65536,
295
+ "callsPerMinute": 60,
296
+ "errors": [
297
+ "tooLarge",
298
+ "missingSignature",
299
+ "badSignature",
300
+ "stale",
301
+ "replayed",
302
+ "notJson"
303
+ ],
304
+ "httpStatus": {
305
+ "tooLarge": 413,
306
+ "missingSignature": 401,
307
+ "badSignature": 401,
308
+ "stale": 401,
309
+ "replayed": 409,
310
+ "notJson": 400,
311
+ "unknownHook": 404,
312
+ "rateLimited": 429,
313
+ "accepted": 202
314
+ },
315
+ "secretCases": [
316
+ {
317
+ "bytes": "07 x 32",
318
+ "expected": "whsec_BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc"
319
+ }
320
+ ],
321
+ "signatureCases": [
322
+ {
323
+ "secret": "whsec_test",
324
+ "timestamp": 1762156800,
325
+ "body": "{\"id\":\"evt_1\",\"amount\":42}",
326
+ "expected": "t=1762156800,v1=65aec49d19e905035d361c88f7659132fe978e4560b8fc0d40c4a31d6e140d98"
327
+ },
328
+ {
329
+ "secret": "whsec_test",
330
+ "timestamp": 1762156800,
331
+ "body": "",
332
+ "expected": "t=1762156800,v1=1ae5daffdd9e6a360e19c164a3f86bfa982a4e77395154c9c6244aed6a24efa2"
333
+ }
334
+ ],
335
+ "verifyCases": [
336
+ {
337
+ "name": "a fresh, signed delivery",
338
+ "header": "t=1762156800,v1=65aec49d19e905035d361c88f7659132fe978e4560b8fc0d40c4a31d6e140d98",
339
+ "body": "{\"id\":\"evt_1\",\"amount\":42}",
340
+ "bytes": 26,
341
+ "secret": "whsec_test",
342
+ "now": 1762156800,
343
+ "seen": [],
344
+ "expected": {
345
+ "ok": true,
346
+ "nonce": "65aec49d19e905035d361c88f7659132fe978e4560b8fc0d40c4a31d6e140d98",
347
+ "body": {
348
+ "id": "evt_1",
349
+ "amount": 42
350
+ }
351
+ }
352
+ },
353
+ {
354
+ "name": "inside the window (300 s late), spaces and an unknown key ignored",
355
+ "header": " t=1762156800 , v0=abc, v1=65aec49d19e905035d361c88f7659132fe978e4560b8fc0d40c4a31d6e140d98",
356
+ "body": "{\"id\":\"evt_1\",\"amount\":42}",
357
+ "bytes": 26,
358
+ "secret": "whsec_test",
359
+ "now": 1762157100,
360
+ "seen": [],
361
+ "expected": {
362
+ "ok": true,
363
+ "nonce": "65aec49d19e905035d361c88f7659132fe978e4560b8fc0d40c4a31d6e140d98",
364
+ "body": {
365
+ "id": "evt_1",
366
+ "amount": 42
367
+ }
368
+ }
369
+ },
370
+ {
371
+ "name": "a rotated sender: any v1 may match",
372
+ "header": "t=1762156800,v1=e43cf9f21cc32eba3e38d7c6605ef086fe5115241197400b9b4b543133bba0a3,v1=65aec49d19e905035d361c88f7659132fe978e4560b8fc0d40c4a31d6e140d98",
373
+ "body": "{\"id\":\"evt_1\",\"amount\":42}",
374
+ "bytes": 26,
375
+ "secret": "whsec_test",
376
+ "now": 1762156500,
377
+ "seen": [],
378
+ "expected": {
379
+ "ok": true,
380
+ "nonce": "65aec49d19e905035d361c88f7659132fe978e4560b8fc0d40c4a31d6e140d98",
381
+ "body": {
382
+ "id": "evt_1",
383
+ "amount": 42
384
+ }
385
+ }
386
+ },
387
+ {
388
+ "name": "too large, checked first",
389
+ "header": null,
390
+ "body": "{\"id\":\"evt_1\",\"amount\":42}",
391
+ "bytes": 65537,
392
+ "secret": "whsec_test",
393
+ "now": 1762156800,
394
+ "seen": [],
395
+ "expected": {
396
+ "ok": false,
397
+ "error": "tooLarge"
398
+ }
399
+ },
400
+ {
401
+ "name": "no header",
402
+ "header": null,
403
+ "body": "{\"id\":\"evt_1\",\"amount\":42}",
404
+ "bytes": 26,
405
+ "secret": "whsec_test",
406
+ "now": 1762156800,
407
+ "seen": [],
408
+ "expected": {
409
+ "ok": false,
410
+ "error": "missingSignature"
411
+ }
412
+ },
413
+ {
414
+ "name": "no timestamp",
415
+ "header": "v1=65aec49d19e905035d361c88f7659132fe978e4560b8fc0d40c4a31d6e140d98",
416
+ "body": "{\"id\":\"evt_1\",\"amount\":42}",
417
+ "bytes": 26,
418
+ "secret": "whsec_test",
419
+ "now": 1762156800,
420
+ "seen": [],
421
+ "expected": {
422
+ "ok": false,
423
+ "error": "badSignature"
424
+ }
425
+ },
426
+ {
427
+ "name": "an uppercase signature is malformed",
428
+ "header": "t=1762156800,v1=65AEC49D19E905035D361C88F7659132FE978E4560B8FC0D40C4A31D6E140D98",
429
+ "body": "{\"id\":\"evt_1\",\"amount\":42}",
430
+ "bytes": 26,
431
+ "secret": "whsec_test",
432
+ "now": 1762156800,
433
+ "seen": [],
434
+ "expected": {
435
+ "ok": false,
436
+ "error": "badSignature"
437
+ }
438
+ },
439
+ {
440
+ "name": "outside the window",
441
+ "header": "t=1762156800,v1=65aec49d19e905035d361c88f7659132fe978e4560b8fc0d40c4a31d6e140d98",
442
+ "body": "{\"id\":\"evt_1\",\"amount\":42}",
443
+ "bytes": 26,
444
+ "secret": "whsec_test",
445
+ "now": 1762157101,
446
+ "seen": [],
447
+ "expected": {
448
+ "ok": false,
449
+ "error": "stale"
450
+ }
451
+ },
452
+ {
453
+ "name": "another secret's signature",
454
+ "header": "t=1762156800,v1=e43cf9f21cc32eba3e38d7c6605ef086fe5115241197400b9b4b543133bba0a3",
455
+ "body": "{\"id\":\"evt_1\",\"amount\":42}",
456
+ "bytes": 26,
457
+ "secret": "whsec_test",
458
+ "now": 1762156800,
459
+ "seen": [],
460
+ "expected": {
461
+ "ok": false,
462
+ "error": "badSignature"
463
+ }
464
+ },
465
+ {
466
+ "name": "a tampered body",
467
+ "header": "t=1762156800,v1=65aec49d19e905035d361c88f7659132fe978e4560b8fc0d40c4a31d6e140d98",
468
+ "body": "{\"id\":\"evt_1\",\"amount\":4200}",
469
+ "bytes": 28,
470
+ "secret": "whsec_test",
471
+ "now": 1762156800,
472
+ "seen": [],
473
+ "expected": {
474
+ "ok": false,
475
+ "error": "badSignature"
476
+ }
477
+ },
478
+ {
479
+ "name": "a replay of an accepted delivery",
480
+ "header": "t=1762156800,v1=65aec49d19e905035d361c88f7659132fe978e4560b8fc0d40c4a31d6e140d98",
481
+ "body": "{\"id\":\"evt_1\",\"amount\":42}",
482
+ "bytes": 26,
483
+ "secret": "whsec_test",
484
+ "now": 1762156800,
485
+ "seen": [
486
+ "65aec49d19e905035d361c88f7659132fe978e4560b8fc0d40c4a31d6e140d98"
487
+ ],
488
+ "expected": {
489
+ "ok": false,
490
+ "error": "replayed"
491
+ }
492
+ },
493
+ {
494
+ "name": "signed but not JSON",
495
+ "header": "t=1762156800,v1=7771cadafebc038179523cf7de66c8a1c5f720554333303851228919187bf388",
496
+ "body": "not json",
497
+ "bytes": 8,
498
+ "secret": "whsec_test",
499
+ "now": 1762156800,
500
+ "seen": [],
501
+ "expected": {
502
+ "ok": false,
503
+ "error": "notJson"
504
+ }
505
+ }
506
+ ],
507
+ "pathCases": [
508
+ {
509
+ "appId": "5b0c7f0e-0000-4000-8000-000000000001",
510
+ "name": "stripe",
511
+ "expected": "/api/apps/hooks/5b0c7f0e-0000-4000-8000-000000000001/stripe"
512
+ }
513
+ ]
514
+ },
515
+ "auditCases": [
516
+ { "name": "monday_rotation", "run": { "records": { "op": "upsert", "kind": "rotation" } }, "clientId": "automation:monday_rotation", "tool": "records.upsert:rotation" },
517
+ { "name": "notify", "run": { "action": "exponential_comments_create" }, "clientId": "automation:notify", "tool": "exponential_comments_create" },
518
+ { "name": "note", "run": { "upstream": "stripe", "tool": "create_note" }, "clientId": "automation:note", "tool": "stripe/create_note" },
519
+ {"name": "route", "run": {"agent": "triage", "prompt": "x"}, "clientId": "automation:route", "tool": "agent:triage"}
520
+ ]
521
+ }