@oneie/sdk 0.14.13 → 0.15.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.
package/dist/receivers.js CHANGED
@@ -159,6 +159,12 @@ export const RECEIVERS = {
159
159
  summary: "Agency mints a scoped invite; on accept the invitee owns a new workspace parented under the agency",
160
160
  request: z.object({
161
161
  slug: z.string(),
162
+ // The invitee's slug. Prefer this over `slug`: a trusted service caller
163
+ // nominates its OWN workspace with `slug` (the ask route reads payload.slug
164
+ // as the caller identity), so naming the invitee there self-parents the
165
+ // invite and mints credits against no sponsor. Refused server-side when the
166
+ // resolved child equals the sponsor.
167
+ child: z.string().optional(),
162
168
  name: z.string().optional(),
163
169
  email: z.string().email().optional(),
164
170
  plan: z.enum(["free", "pro", "studio", "agency", "enterprise"]).optional(),
@@ -170,7 +176,7 @@ export const RECEIVERS = {
170
176
  }),
171
177
  response: z.object({ token: z.string(), id: z.string(), child: z.string(), expiresAt: z.number() }),
172
178
  effect: "ask", auth: "manage_clients", reversible: false,
173
- examples: [{ slug: "beta", email: "owner@beta.com", plan: "studio", credits: 50000, markup: 20 }],
179
+ examples: [{ slug: "one", child: "beta", email: "owner@beta.com", plan: "studio", credits: 50000, markup: 20 }],
174
180
  }),
175
181
  "world:accept-client-invite": receiver({
176
182
  receiver: "world:accept-client-invite",
@@ -239,6 +245,61 @@ export const RECEIVERS = {
239
245
  request: z.object({ gid: z.string(), name: z.string().optional(), tags: z.array(z.string()).optional(), meta: z.unknown().optional() }),
240
246
  response: ok, effect: "ask", auth: "manage_groups", roleAction: "update_group",
241
247
  }),
248
+ /**
249
+ * The only non-HTTP door to a workspace's brand. Tokens previously reached R2
250
+ * through `pages/api/{branding,settings,onboarding}.ts` only, so a workflow
251
+ * step, an agent, the CLI and MCP could not persist a theme at all.
252
+ *
253
+ * `auth: "manage_workspace"` is chosen because it is already in
254
+ * bind-receiver.ts's AUTH_POLICY mapping to the ENFORCED `tenant` class — an
255
+ * invented label would fall through `AUTH_POLICY[label] ?? 'authenticated'`
256
+ * to the WARN-only class and ship a workspace write with no enforced floor.
257
+ *
258
+ * `cost: "free"` is a declaration, not an omission: an R2 read+put costs no
259
+ * provider dollars, and per text/credit-allocation.md an UNDECLARED cost must
260
+ * not read as free. `reversible: false` is honest — the response reports which
261
+ * keys were written but not their prior values, so the caller cannot restore
262
+ * them from it. Handler: one.ie/web/src/lib/resolvers/brand.ts.
263
+ *
264
+ * The write is held to WCAG AA by the SAME `auditBrandContrast` the brand
265
+ * editor route calls, so a theme written by a workflow step meets the bar a
266
+ * human-written one does.
267
+ */
268
+ "world:set-brand-tokens": receiver({
269
+ receiver: "world:set-brand-tokens",
270
+ summary: "Write validated colour tokens to a workspace's brand (site.md)",
271
+ request: z.object({
272
+ /** The workspace being rebranded. The ONLY workspace field: authority is
273
+ * decided against this value and it is the only one written to. */
274
+ workspace: z.string(),
275
+ /** Frontmatter keys to set. The six light tokens (primary, secondary,
276
+ * tertiary, background, foreground, font) and their `dark-` overrides;
277
+ * any other key is ignored, never written. `"unset"` on a `dark-` key
278
+ * deletes the override, re-linking it to the light value. Values are
279
+ * syntax-gated with the same HEX_OR_HSL regex `parseSite` reads with, then
280
+ * checked readable with `parseColor`, then held to WCAG AA by the same
281
+ * `auditBrandContrast` the brand editor route calls. Any invalid value —
282
+ * or any AA failure on a pair THIS write touches — refuses the WHOLE
283
+ * write; pre-existing failures come back as `warnings`. */
284
+ tokens: z.record(z.string(), z.string()),
285
+ }),
286
+ response: z.object({
287
+ ok: z.boolean(),
288
+ workspace: z.string().optional(),
289
+ written: z.array(z.string()).optional(),
290
+ bytes: z.number().optional(),
291
+ ignored: z.array(z.string()).optional(),
292
+ error: z.string().optional(),
293
+ invalid: z.array(z.object({ field: z.string(), value: z.string(), reason: z.string() })).optional(),
294
+ /** WCAG AA pairs THIS write touches that fail — the write is refused. */
295
+ failures: z.array(z.record(z.string(), z.unknown())).optional(),
296
+ /** Pairs it leaves alone that already fail — reported, never blocking. */
297
+ warnings: z.array(z.record(z.string(), z.unknown())).optional(),
298
+ }),
299
+ effect: "ask", auth: "manage_workspace", roleAction: "update_group",
300
+ cost: "free", reversible: false, idempotent: true, settles: "none",
301
+ examples: [{ workspace: "acme", tokens: { primary: "#1a73e8", "dark-primary": "#8ab4f8" } }],
302
+ }),
242
303
  "world:remove-group": receiver({
243
304
  receiver: "world:remove-group",
244
305
  summary: "Delete a group",
@@ -440,6 +501,73 @@ export const RECEIVERS = {
440
501
  effect: "ask", auth: "manage_integrations", reversible: false,
441
502
  examples: [{ slug: "acme", toolkit: "stripe", credentials: { api_key: "sk_test_..." } }],
442
503
  }),
504
+ // git:pr — open or update ONE pull request on the repository a workspace owns.
505
+ //
506
+ // THE REPO IS NOT A PARAMETER, and that is the security property, not an
507
+ // ergonomic one. `bind-receiver.ts` splits the work: the binder asks "does this
508
+ // caller hold this CLASS of authority?", the handler asks "…over this
509
+ // PARTICULAR object?". Every other tenant receiver can answer the second
510
+ // because its object is a node in the six dimensions. A GitHub repository is
511
+ // not one — grep every schema/*.tql and the only hit is a `front-door`
512
+ // attribute whose VALUE may be the string "github". So `git:pr({ repo, … })`
513
+ // would take its target from the body and its credential from env: workspace
514
+ // authority anywhere becoming a write to any repo the token can reach, signed
515
+ // by ONE. That is the confused deputy, and it is the same defect class as
516
+ // trusting an actorId from a request body.
517
+ //
518
+ // So the caller never names the repository. The object of the decision is the
519
+ // WORKSPACE — dimension 1, a real node — resolved from the attested
520
+ // `ctx.ownerSlug` exactly as `tools:composio` resolves its entity ("never a
521
+ // body field", in that resolver's own words), and the repo is looked up FROM
522
+ // the workspace. No binding is a refusal, never a fallback to a default repo.
523
+ //
524
+ // `auth` is `manage_integrations` because it must be: AUTH_POLICY maps that to
525
+ // the `tenant` floor, and an unrecognised label falls to `authenticated`
526
+ // (bind-receiver.ts:120) — a novel name would have shipped this WEAKER, and
527
+ // nothing in the suite would have said so.
528
+ //
529
+ // It cannot derive its own title or body: there is no git and no shell in a
530
+ // Worker. Those are the caller's, which is why this receiver is the DOOR and
531
+ // `.claude/scripts/pr-body.sh` is the document.
532
+ "git:pr": receiver({
533
+ receiver: "git:pr",
534
+ summary: "Open or update one pull request on the repository this workspace is bound to — the repo is resolved from the workspace, never from the payload",
535
+ request: z.object({
536
+ workspace: z.string().optional().describe("Workspace slug; defaults to the attested caller's. The bound repo is resolved FROM this."),
537
+ head: z.string().describe("Branch to merge from — must already be pushed"),
538
+ base: z.string().optional().describe("Branch to merge into; defaults to main"),
539
+ title: z.string().describe("PR title"),
540
+ body: z.string().optional().describe("PR body — derive it with .claude/scripts/pr-body.sh; a Worker cannot"),
541
+ draft: z.boolean().optional().describe("Open as a draft"),
542
+ }),
543
+ response: z.object({
544
+ ok: z.boolean(),
545
+ url: z.string().optional(),
546
+ number: z.number().optional(),
547
+ updated: z.boolean().optional().describe("true = an open PR was updated rather than a new one created"),
548
+ repo: z.string().optional(),
549
+ error: z.string().optional(),
550
+ detail: z.string().optional(),
551
+ }),
552
+ effect: "ask",
553
+ auth: "manage_integrations",
554
+ // A PR is closable, and a re-run updates the open one rather than opening a
555
+ // second — so a retried workflow step is safe as-is.
556
+ reversible: true,
557
+ idempotent: true,
558
+ cost: "free",
559
+ settles: "none",
560
+ version: "1.0.0",
561
+ examples: [{ head: "feat/x", base: "main", title: "feat: x" }],
562
+ }),
563
+ "tools:composio": receiver({
564
+ receiver: "tools:composio",
565
+ summary: "Execute one connected Composio tool from a workflow `tool` step (config.composio)",
566
+ request: z.object({ workspace: z.string().optional().describe("Workspace slug; defaults to the attested caller's"), tool: z.string().describe("Composio tool slug to execute"), args: z.record(z.string(), z.unknown()).optional().describe("Arguments passed to the tool") }),
567
+ response: z.object({ ok: z.boolean(), tool: z.string().optional(), result: z.unknown().optional(), error: z.string().optional() }),
568
+ effect: "ask", auth: "manage_integrations", reversible: false, idempotent: false,
569
+ examples: [{ workspace: "acme", tool: "GMAIL_SEND_EMAIL", args: { to: "x@y.com" } }],
570
+ }),
443
571
  "brand:set": receiver({
444
572
  receiver: "brand:set",
445
573
  summary: "Write up to 6 brand color tokens (primary/secondary/accent/bg/text/border) to a workspace theme",
@@ -947,6 +1075,70 @@ export const RECEIVERS = {
947
1075
  }),
948
1076
  effect: "ask", idempotent: true,
949
1077
  }),
1078
+ "factory:size": receiver({
1079
+ receiver: "factory:size",
1080
+ summary: "How big is this change — the tier `.claude/scripts/do-tier.sh` returns for the same paths, answered where a Worker can ask (a Worker cannot run a shell script, which is the only reason the Size card at factory flow.ts:306 is a gap). Zero real paths is REFUSED as `unsized`, mirroring the script's exit 3: absence of recon must not read as simplicity. The pin ladder in the response is a pure function of the tier; the surfaces are additive obligation, never a second gate selector",
1081
+ request: z.object({
1082
+ // The changed paths, repo-relative. do-tier sizes a DIFF, never a sentence,
1083
+ // so this is the only field that can decide the answer.
1084
+ paths: z.array(z.string()).max(2000),
1085
+ // Free text. It can only RAISE the answer to FEATURE, and is ignored entirely
1086
+ // when `paths` is empty — "add a null check" and "add a settings page" share a verb.
1087
+ intent: z.string().max(2000).optional(),
1088
+ }),
1089
+ response: z.object({
1090
+ ok: z.boolean(),
1091
+ tier: z.enum(["PATCH", "FIX", "FEATURE", "SCHEMA"]).optional(),
1092
+ spine: z.string().optional(),
1093
+ classifier: z.string().optional(),
1094
+ ceilingTokens: z.number().optional(),
1095
+ // The pinned suites this tier buys — derived from `tier` alone, mirroring
1096
+ // .claude/scripts/verify-fast.sh:302-306. There is no second selector.
1097
+ pins: z.array(z.string()).optional(),
1098
+ // What the paths touch, and the ceremony rung after every surface floor is
1099
+ // applied. Obligations, not gate selection.
1100
+ surfaces: z.array(z.string()).optional(),
1101
+ risk: z.string().optional(),
1102
+ ratcheted: z.boolean().optional(),
1103
+ produces: z.array(z.object({ what: z.string(), source: z.string() })).optional(),
1104
+ gates: z.array(z.object({ what: z.string(), source: z.string() })).optional(),
1105
+ error: z.string().optional(),
1106
+ }),
1107
+ effect: "ask", idempotent: true, reversible: true,
1108
+ }),
1109
+ "factory:recon": receiver({
1110
+ receiver: "factory:recon",
1111
+ summary: "What does this sentence touch — the paths, the route and the verdict `.claude/scripts/do-triage.sh` returns for the same text, answered where a Worker can ask (a Worker cannot exec a shell script, which is the only reason the recon card at factory flow.ts:480 was a gap). Runs UPSTREAM of factory:size: prose in, repo paths out, and the sizer's own contract unchanged. No model call. It ALWAYS answers with a verdict and never refuses — it runs as a workflow tool step (one.ie/ai/workflows/eng-intake.tql:33), where a failed step poisons every step downstream of it; factory:size's `unsized` stays the one refusal on this lane",
1112
+ request: z.object({
1113
+ // The captured sentence. do-triage reads what the text LITERALLY names —
1114
+ // there is no model here, so a sentence that names nothing answers `idea`.
1115
+ intent: z.string().max(2000),
1116
+ // Paths a caller already knows. Accepted so a workflow step can pass the
1117
+ // trigger through whole; recon derives its own from `intent` and does not
1118
+ // widen the answer with these.
1119
+ paths: z.array(z.string()).max(2000).optional(),
1120
+ // The trigger's tags, carried not read. Declared for one measured reason:
1121
+ // zod `.object()` STRIPS unknown keys, so an undeclared field arriving from
1122
+ // a workflow step is a shape error rather than a no-op. It decides nothing —
1123
+ // same rule as factory:size's `workspace`: a body field that decided nothing
1124
+ // cannot be spoofed into deciding something.
1125
+ tags: z.array(z.string()).max(64).optional(),
1126
+ }),
1127
+ response: z.object({
1128
+ // Always true. There is no `ok:false` branch in this receiver.
1129
+ ok: z.boolean(),
1130
+ verdict: z.enum(["sizeable", "idea", "dupe", "split"]).optional(),
1131
+ // Repo-relative, existing, in the order the sentence names them. This is the
1132
+ // array that goes to factory:size unchanged.
1133
+ paths: z.array(z.string()).optional(),
1134
+ route: z.string().nullable().optional(),
1135
+ // Every path-SHAPED token the sentence named, before the existence filter.
1136
+ // The edge can confirm fewer paths than the shell can (it has a page glob,
1137
+ // not a repo), and this is that difference made legible rather than hidden.
1138
+ named: z.array(z.string()).optional(),
1139
+ }),
1140
+ effect: "ask", idempotent: true, reversible: true,
1141
+ }),
950
1142
  "factory:attempt": receiver({
951
1143
  receiver: "factory:attempt",
952
1144
  summary: "Open an attempt contained by its task, or close it with the production edge to what it produced plus the rubric. A do-cycle IS an attempt (factory-plan.md §3.1) — do:cycle-close arms as a caller of the close phase, never as a second writer",
@@ -986,6 +1178,257 @@ export const RECEIVERS = {
986
1178
  }),
987
1179
  effect: "ask", idempotent: true,
988
1180
  }),
1181
+ "deploy:event": receiver({
1182
+ receiver: "deploy:event",
1183
+ summary: "One deploy gate lands on the same run/event stream the canvas already reads — the ten-gate pipeline (tree·credentials·typecheck·tests·build·smoke·approval·migrations·ship·health) that until now touched the substrate NOWHERE, leaving a committed JSON file as a deploy's only trace. Carries the metrics that file has no shape for: whether the suite was REUSED or re-run, whether a gate passed under a waiver, and which services actually shipped. An unknown stage, status or verdict is REFUSED, never recorded as a default frame",
1184
+ request: z.object({
1185
+ // The deploy's own run id — deploy.sh already mints one for its log name.
1186
+ // It IS half the run key, which is why a retried gate appends to its own
1187
+ // run instead of minting a second one. The sha cannot serve: the same sha
1188
+ // ships to the same target repeatedly (2026-09-06: af3ba5919 gates-only,
1189
+ // red, green, then shipped), and keying on it would interleave them.
1190
+ run: z.string(),
1191
+ // Where it ships. The other half of the key, so "did pay go out" is
1192
+ // answerable without disambiguating five services by timestamp.
1193
+ target: z.string(),
1194
+ // Must equal DEPLOY_SPINE_STEPS (one.ie/web/src/lib/deploy/event.ts).
1195
+ // Retyped because packages/sdk cannot import from one.ie/web; pinned by
1196
+ // tests/unit/deploy/event.test.ts so the two cannot drift.
1197
+ stage: z.enum([
1198
+ "tree", "credentials", "typecheck", "tests", "build",
1199
+ "smoke", "approval", "migrations", "ship", "health",
1200
+ ]).optional(),
1201
+ // There is deliberately no "skip": a gate that did not run must be ABSENT.
1202
+ // deploy.sh already reports four gate states (pass|fail|unrun|n/a) and an
1203
+ // unrun gate is never a pass — a "skip" frame would render like one.
1204
+ status: z.enum(["start", "ok", "fail"]).optional(),
1205
+ // Present only on the final call, and it is what CLOSES the run. Separate
1206
+ // from the stages because --gates-only is a complete, successful run that
1207
+ // legitimately never reaches `ship` or `health`; closing on the last stage
1208
+ // would leave every one of those runs "open" forever.
1209
+ verdict: z.enum(["green", "red"]).optional(),
1210
+ sha: z.string().optional(),
1211
+ // Which door ran it: ./deploy, release.sh ship, land --pr --deploy.
1212
+ door: z.string().optional(),
1213
+ // Board workspace. TAGS ONLY inside the resolver — effectiveWorkspace
1214
+ // reads `workspace` and never `slug`. The same caller-nomination note as
1215
+ // factory:event's `slug` field applies verbatim for a service caller.
1216
+ slug: z.string().optional(),
1217
+ // "dev" flags the projected run is_test so dev.one.ie never pollutes the
1218
+ // production list. --dry-run does not reach here at all: the emitter is
1219
+ // not called on that path, because a run row for a deploy that did not
1220
+ // happen is the theater /deploy already refuses to render.
1221
+ env: z.string().optional(),
1222
+ reason: z.string().optional(),
1223
+ // This gate's own wall clock. Written to workflow_run_event.latency_ms —
1224
+ // the column factory:event binds NULL because a factory stage has no
1225
+ // measured duration. It is the number this whole receiver exists for.
1226
+ wallMs: z.number().optional(),
1227
+ // reused / waived / services — see DeployEventPayload.detail.
1228
+ detail: z.record(z.string(), z.unknown()).optional(),
1229
+ workspace: z.string().optional(),
1230
+ }),
1231
+ response: z.object({
1232
+ ok: z.boolean(),
1233
+ runId: z.string().optional(),
1234
+ stage: z.string().optional(),
1235
+ status: z.string().optional(),
1236
+ verdict: z.string().optional(),
1237
+ events: z.number().optional(),
1238
+ slug: z.string().optional(),
1239
+ error: z.string().optional(),
1240
+ }),
1241
+ // Same floor and same reasoning as factory:event: authClassFor(undefined)
1242
+ // returns 'open', and the 'authenticated' floor tests exactly the predicate
1243
+ // the handler's own guard uses, one layer earlier. Declaring it makes the
1244
+ // fail-closed intent survive a refactor of the handler.
1245
+ auth: "required",
1246
+ effect: "ask", idempotent: true,
1247
+ }),
1248
+ "factory:event": receiver({
1249
+ receiver: "factory:event",
1250
+ summary: "One factory-executor stage lands on the same run/event stream the canvas already reads — the six-stage pipeline (ready·claim·build·review·prove·close) that until now touched the substrate only at claim and close, so every surface could show a job before and after but never during. An unknown stage or status is REFUSED, never recorded as a default frame",
1251
+ request: z.object({
1252
+ // The task id being built. It IS the run key (`factory:<job>`), which is why
1253
+ // a retried stage appends to its own run instead of minting a second one.
1254
+ job: z.string(),
1255
+ // Must equal FACTORY_SPINE_STEPS (one.ie/web/src/lib/factory/event.ts).
1256
+ // Retyped because packages/sdk cannot import from one.ie/web; pinned by
1257
+ // tests/unit/factory/event-receiver.test.ts so the two cannot drift.
1258
+ stage: z.enum(["ready", "claim", "build", "review", "prove", "close"]),
1259
+ // There is deliberately no "skip": a stage that did not run must be ABSENT.
1260
+ status: z.enum(["start", "ok", "fail"]),
1261
+ // Board workspace. TAGS ONLY *inside the resolver* — effectiveWorkspace
1262
+ // (resolvers/tasks.ts:35) reads `workspace` and never `slug`, so the resolver
1263
+ // itself cannot be steered by this field.
1264
+ //
1265
+ // BUT NOT BEFORE IT. For a VERIFIED SERVICE CALLER the ask route nominates
1266
+ // the identity from the body and `slug` WINS:
1267
+ // pages/api/ask/[...receiver].ts:237
1268
+ // nominated = typeof p.slug === 'string' ? p.slug
1269
+ // : typeof p.workspace === 'string' ? p.workspace : undefined
1270
+ // :324 ownerSlug = locals.slug ?? callerUid ?? serviceOwnerSlug
1271
+ // A sessionless curl (no `locals.slug`, and `callerUid` is only resolved in the
1272
+ // `bearerToken && !isServiceCaller` branch) therefore lands ctx.ownerSlug =
1273
+ // data.slug, and effectiveWorkspace returns that owner unchanged. So on the
1274
+ // shipped emitter — .claude/scripts/factory-emit.sh, which posts
1275
+ // `Authorization: Bearer $GATEWAY_API_KEY` (isVerifiedServiceCaller case 2,
1276
+ // gateway-guard.ts:118) and sends `--slug` but never `workspace` — the `slug`
1277
+ // COLUMN is data.slug, chosen by the caller. That is by design for a caller
1278
+ // holding a shared service secret, and it is NOT what "never authz" says.
1279
+ // The tags/authz split is a property of the RESOLVER, not of this field.
1280
+ slug: z.string().optional(),
1281
+ model: z.string().optional(),
1282
+ detail: z.record(z.string(), z.unknown()).optional(),
1283
+ reason: z.string().optional(),
1284
+ // "dev" flags the projected run is_test so a worktree never pollutes the list.
1285
+ env: z.string().optional(),
1286
+ // Which authorized workspace the `slug` COLUMN resolves to (effectiveWorkspace,
1287
+ // resolvers/tasks.ts). Never identity: an unauthorized value falls back to the
1288
+ // attested ownerSlug rather than escalating.
1289
+ workspace: z.string().optional(),
1290
+ }),
1291
+ response: z.object({
1292
+ ok: z.boolean(),
1293
+ runId: z.string().optional(),
1294
+ stage: z.string().optional(),
1295
+ status: z.string().optional(),
1296
+ events: z.number().optional(),
1297
+ slug: z.string().optional(),
1298
+ error: z.string().optional(),
1299
+ }),
1300
+ // VERIFIED, not assumed: authClassFor(undefined) returns 'open'
1301
+ // (bind-receiver.ts:117-120), and the 'authenticated' floor tests exactly
1302
+ // `ctx.staff === true || Boolean(ctx.ownerSlug)` (bind-receiver.ts:159) — the
1303
+ // same predicate the handler's own guard uses, one layer earlier, never
1304
+ // stricter. The four siblings omit the label; declaring it here makes the
1305
+ // fail-closed intent survive a refactor of the handler.
1306
+ //
1307
+ // HOW THE FLOOR ACTUALLY CLEARS — the previous note here said "the executor's
1308
+ // world-key call carries `data.workspace`, which the ask route turns into
1309
+ // ctx.ownerSlug". Both halves were wrong. The shipped emitter
1310
+ // (.claude/scripts/factory-emit.sh) is NOT a world-key call: it posts
1311
+ // `Authorization: Bearer $GATEWAY_API_KEY`, which is isVerifiedServiceCaller
1312
+ // case 2 (gateway-guard.ts:118) — the SERVICE-secret door, not the per-actor
1313
+ // one. A world-key bearer takes the other branch entirely
1314
+ // (`bearerToken && !isServiceCaller`, [...receiver].ts:238) and resolves
1315
+ // `callerUid`, never `serviceOwnerSlug`. And it does not send `workspace` at
1316
+ // all; it sends `slug`, which is the FIRST nomination at :237 and therefore
1317
+ // wins for a service caller. So the floor clears on a caller-supplied `slug`
1318
+ // reaching ctx.ownerSlug — see the note on the `slug` field above.
1319
+ auth: "required",
1320
+ // Two `build/ok` events for one job are two real frames with rising seq, not
1321
+ // a dedupe. The RUN row dedupes (INSERT OR IGNORE); the events do not.
1322
+ effect: "ask", idempotent: false,
1323
+ }),
1324
+ "story:chain": receiver({
1325
+ receiver: "story:chain",
1326
+ summary: "The stories, counted: sets of two or more signals sharing one `origin`, read from the D1 lineage ledger (migrations/0244_story_link.sql). A story is a COUNT over a shared origin, not an entity \u2014 zero new types, zero new dimensions. The number moves as a side effect of signals that already flow; nobody types a row. `count` is the whole qualifying population, `stories` a page of at most 50 ordered by members desc \u2014 a capped count would plateau exactly when the substrate got busy. Zero is a real answer, never an error",
1327
+ request: z.object({
1328
+ // Where "a story is two or more" lives. The floor is the caller's, so 1
1329
+ // legitimately means "every origin"; the default is the contract's 2.
1330
+ minMembers: z.number().int().min(1).max(1_000_000).optional(),
1331
+ // Narrow to one story. The same floor still applies: an origin holding a
1332
+ // single signal answers count:0, because one signal is not yet a story.
1333
+ origin: z.string().max(128).optional(),
1334
+ // Page size only. Capped at 50 by the reader; it can never widen the scan.
1335
+ limit: z.number().int().min(1).max(50).optional(),
1336
+ }),
1337
+ response: z.object({
1338
+ ok: z.boolean(),
1339
+ count: z.number(),
1340
+ minMembers: z.number().optional(),
1341
+ stories: z.array(z.object({
1342
+ origin: z.string(),
1343
+ members: z.number(),
1344
+ // NULLABLE on purpose: `ts` is nullable in 0244, so MIN/MAX over a row
1345
+ // that carried none is NULL. Typing these bare numbers would be a lie
1346
+ // the response validator eventually catches in prod.
1347
+ first: z.number().nullable().optional(),
1348
+ last: z.number().nullable().optional(),
1349
+ })).optional(),
1350
+ }),
1351
+ // PUBLIC, and the reason is the ledger's shape, not an oversight. 0244 stores
1352
+ // signal_id, origin, receiver and ts \u2014 "no actor, no payload and no ip", in
1353
+ // its own words \u2014 so there is nothing here to scope to a tenant and nothing
1354
+ // to leak by reading it. The alternative is worse than permissive: the binder
1355
+ // attests on `staff || ownerSlug` (bind-receiver.ts applyAuth), and a service
1356
+ // caller nominates ownerSlug only via a `slug`/`workspace` body field, which
1357
+ // this contract has no reason to carry \u2014 so an `authenticated` label would
1358
+ // refuse the very call the promise's Proof makes.
1359
+ auth: "public",
1360
+ // A pure read. Same inputs, same answer, until a signal moves the number.
1361
+ effect: "ask", idempotent: true, reversible: true,
1362
+ }),
1363
+ // story:event — the funnel's doors. text/story-framework.md § "The story is
1364
+ // alive" names nine steps and schema/story.tql block 4 declares the EVENT
1365
+ // CONVENTION they are counted by; until this receiver existed nothing in the
1366
+ // estate WROTE one, so every block-4 fun answered 0 and block 5's story_death
1367
+ // answered "queen" for every story that had ever been told
1368
+ // (text/story-tracking-plan.md § Gaps 4). This is the write half.
1369
+ //
1370
+ // ONE signal per call, under the story's origin, carrying the block-4 payload
1371
+ // pairs: {"origin":"<o>","event":"story:<kind>"} plus "parent" on a retell and
1372
+ // an `amount` attribute on a convert. The graph write and the trail marks are
1373
+ // deferred to waitUntil — the brain takes the write, never the request path
1374
+ // (root CLAUDE.md § The brain and the edge).
1375
+ //
1376
+ // AUTH IS PER-KIND, which no single label can express, so the label is the
1377
+ // widest kind and the resolver holds the rest: `told`, `convert` and `retell`
1378
+ // refuse an unattested caller (they name a teller, move money, or claim
1379
+ // lineage), while `framed`/`view`/`complete`/`share` are open — a reader of a
1380
+ // published story has no session and a view nobody can send is not a funnel.
1381
+ // The identity is ALWAYS ctx, never the body (memory: resolver-body-actorid).
1382
+ "story:event": receiver({
1383
+ receiver: "story:event",
1384
+ summary: "One step of a story's life, written as a signal under its origin: told · framed · view · complete · share · convert · retell. The payload carries the block-4 event convention (schema/story.tql), so story_views / story_conversions / story_revenue / story_retells / story_teller start answering from real traffic instead of 0. A convert also marks the two craft trails — tag:<board>:story → tag:<board>:frame:<x> and → tag:<board>:medium:<x> — and a retell marks teller → reteller, the referral edge. told/convert/retell need an attested caller; view/complete/share may be anonymous and are deduped per (origin, viewer, event) for an hour",
1385
+ request: z.object({
1386
+ // The story. Minted by the door (C3) and echoed on every response, so a
1387
+ // caller always has one to quote; never derived from a task id.
1388
+ origin: z.string().min(1).max(128),
1389
+ // The nine-step funnel's kinds, minus the four a rung already writes
1390
+ // (promised/building/settled are the promise's own states).
1391
+ event: z.enum(["told", "framed", "view", "complete", "share", "convert", "retell"]),
1392
+ // Revenue on a convert. 0 is a real answer — a sign-up converts and moves
1393
+ // no money — so it is not conflated with absent.
1394
+ amount: z.number().nonnegative().max(1_000_000).optional(),
1395
+ // REQUIRED on a retell: the origin of the story this one was told from.
1396
+ // A retell without it is lineage that names no parent, which is what
1397
+ // story_children reads, so it is refused rather than written half-formed.
1398
+ parent: z.string().max(128).optional(),
1399
+ // What converted — the framework and the medium. Named on a convert, they
1400
+ // become the two marks; absent, the convert still counts and marks nothing.
1401
+ frame: z.string().max(64).optional(),
1402
+ medium: z.string().max(64).optional(),
1403
+ // TENANCY: honoured only when it equals the attested caller's own slug.
1404
+ // A caller that could name another board could poison another tenant's
1405
+ // routing weights (resolvers/subscriptions.ts:399-403).
1406
+ board: z.string().max(64).optional(),
1407
+ // Whether the medium played to the end — the one thing story_completes
1408
+ // reads `has success true` for. Defaults true.
1409
+ success: z.boolean().optional(),
1410
+ }),
1411
+ response: z.object({
1412
+ ok: z.boolean(),
1413
+ origin: z.string().optional(),
1414
+ // The full pair as written, e.g. "story:view" — so a caller can grep the
1415
+ // same string schema/story.tql matches on.
1416
+ event: z.string().optional(),
1417
+ // TRUE means the window already held this (origin, viewer, event) and no
1418
+ // second signal was written. A real answer, never an error.
1419
+ deduped: z.boolean().optional(),
1420
+ // The trails this call will move, as "<source>→<target>". Empty is honest:
1421
+ // a convert naming no frame and no medium marks nothing.
1422
+ marked: z.array(z.string()).optional(),
1423
+ error: z.string().optional(),
1424
+ }),
1425
+ // The widest kind. See the note above: the narrower kinds are gated in the
1426
+ // resolver on ctx, because one label cannot say "open to read, closed to tell".
1427
+ auth: "public",
1428
+ // It writes. Not idempotent: two views an hour apart are two views, and the
1429
+ // dedupe window is a window, not a key.
1430
+ effect: "signal", cost: "free", reversible: false, idempotent: false,
1431
+ }),
989
1432
  "do:halt": receiver({
990
1433
  receiver: "do:halt",
991
1434
  summary: "Stop a factory run, or release the stop. Sets a halt latch on the plan thing that `factory:attempt` phase \"open\" refuses against, so no new attempt can be claimed; with `attempt`, also closes that in-flight attempt as dissolved. It does NOT kill an OS process — a cycle already executing stops at its next substrate write",
@@ -1024,13 +1467,18 @@ export const RECEIVERS = {
1024
1467
  // Prose goal / what "done" looks like — the context a human attaches at create so
1025
1468
  // the task is pickable by an agent or a Claude Code session without a round trip.
1026
1469
  notes: z.string().optional(),
1470
+ // Optional parent tid — writes the `containment` edge in the SAME pipeline as the
1471
+ // row, so the factory's downward walk (fn ready-tasks -> derived-from) can reach the
1472
+ // task. Without it the task is an orphan: on the board, unreachable from any plan.
1473
+ // Refused unless the caller has operate access to the parent.
1474
+ parent: z.string().optional(),
1027
1475
  // The viewed /u/<slug> workspace to file the task under. Honored only when the
1028
1476
  // caller is authorized for it (attested staff or owner-tree control); otherwise
1029
1477
  // the resolver falls back to the caller's own slug. Reconciles the create tag
1030
1478
  // with the /api/things read filter so a created task survives reload.
1031
1479
  workspace: z.string().optional(),
1032
1480
  }),
1033
- response: z.object({ ok: z.boolean(), tid: z.string().optional(), tags: z.array(z.string()).optional() }),
1481
+ response: z.object({ ok: z.boolean(), tid: z.string().optional(), tags: z.array(z.string()).optional(), parent: z.string().optional() }),
1034
1482
  effect: "ask", idempotent: false,
1035
1483
  }),
1036
1484
  "tasks:claim": receiver({
@@ -1140,12 +1588,70 @@ export const RECEIVERS = {
1140
1588
  }),
1141
1589
  effect: "ask", idempotent: true, auth: "member",
1142
1590
  }),
1591
+ // tasks:generate — read what a page says, propose the work it implies, and file it.
1592
+ //
1593
+ // The one task verb that does not take a title: the caller hands over the PAGE
1594
+ // (its title, its own summary, its visible text) and gets back real rows on the
1595
+ // board. Every row is written through `tasks:create`, so the workspace gate, the
1596
+ // announce and the write-failure semantics are the ones that verb already holds —
1597
+ // this receiver only decides WHAT to file, never who may file it.
1598
+ //
1599
+ // No URL is fetched server-side. The caller sends the text it can already see
1600
+ // (the browser reads its own DOM, or same-origin-fetches a page under the
1601
+ // viewer's own session); a receiver that fetched an arbitrary `url` would be an
1602
+ // SSRF door on a surface that spends LLM tokens. `url` is a LABEL here — it
1603
+ // names the source and seeds the dedupe key, and is never dereferenced.
1604
+ //
1605
+ // Idempotent by (source, title): each row carries a derived `slug:` tag, which
1606
+ // tasks:create dedupes on. Clicking twice on the same page cannot double the
1607
+ // board, while a genuinely new suggestion still lands.
1608
+ "tasks:generate": receiver({
1609
+ receiver: "tasks:generate",
1610
+ surfaces: { mcp: true },
1611
+ summary: "Turn a page into tasks: read the page text the caller supplies, propose 3-7 concrete next actions, and write each one through tasks:create (deduped by source+title). Never fetches the URL — `url` labels the source and seeds the dedupe key",
1612
+ request: z.object({
1613
+ /** Source label + dedupe seed. Never fetched. */
1614
+ url: z.string().optional(),
1615
+ title: z.string().optional(),
1616
+ /** The page's own description / heading trail — what `readPageSummary()` returns. */
1617
+ summary: z.string().optional(),
1618
+ /** Visible page text, if the caller has it. Capped server-side. */
1619
+ text: z.string().optional(),
1620
+ /** How many tasks to aim for. Clamped to 1-8. */
1621
+ count: z.number().optional(),
1622
+ /** Extra tags every generated task carries, on top of `from-page`. */
1623
+ tags: z.array(z.string()).optional(),
1624
+ /** The viewed /u/<slug> workspace — same authorization contract as tasks:create. */
1625
+ workspace: z.string().optional(),
1626
+ }),
1627
+ response: z.object({
1628
+ ok: z.boolean(),
1629
+ created: z.number().optional(),
1630
+ deduped: z.number().optional(),
1631
+ /** The workspace the rows actually landed in — the resolver's own answer,
1632
+ * not the caller's request. A caller that guessed this instead would link
1633
+ * the operator at a board their tasks are not on whenever the workspace
1634
+ * override was refused. */
1635
+ workspace: z.string().optional(),
1636
+ tasks: z.array(z.object({ tid: z.string(), title: z.string(), deduped: z.boolean().optional() })).optional(),
1637
+ error: z.string().optional(),
1638
+ }),
1639
+ // Spends LLM tokens and writes rows. An undeclared label is an anonymous door
1640
+ // (see receiver-envelope.ts § requiresAttestedCaller) — this one refuses.
1641
+ effect: "ask", auth: "member", reversible: false, idempotent: true,
1642
+ }),
1143
1643
  "tasks:list": receiver({
1144
1644
  receiver: "tasks:list",
1145
1645
  surfaces: { mcp: true },
1146
1646
  summary: "Every task carrying a tag, with its task-status — the read that lets a plan's checkboxes be a PROJECTION of task state instead of a second copy. Scoped to the caller's workspace on the server",
1147
1647
  request: z.object({
1148
1648
  tag: z.string(),
1649
+ // Optional task-status filter (open|blocked|picked|done|verified|failed|dissolved).
1650
+ // Declared here because the HTTP edge dispatches zod's PARSED output — an
1651
+ // undeclared field is silently stripped before the resolver reads it, and the
1652
+ // factory turn (one.ie/ai/workflows/factory-turn.tql step:rows) needs the
1653
+ // plan's OPEN rows to fan tasks:launch over.
1654
+ status: z.string().optional(),
1149
1655
  workspace: z.string().optional(),
1150
1656
  }),
1151
1657
  response: z.object({
@@ -1267,6 +1773,26 @@ export const RECEIVERS = {
1267
1773
  slug: z.string().optional(),
1268
1774
  body: z.string().optional(),
1269
1775
  workspace: z.string().optional(),
1776
+ // WHICH CONVERSATION THIS CAME FROM. A comment and a chat message already
1777
+ // live in the same D1 message store (`getOrCreateThread`, keyed
1778
+ // `task:<tid>`); what was missing was a field naming the OTHER thread, so
1779
+ // a discussion in the inbox and the row it is about could not be joined.
1780
+ //
1781
+ // It must be DECLARED to exist: `validateReceiver` returns the parsed
1782
+ // object and a zod schema STRIPS undeclared keys, so an undeclared field
1783
+ // never reaches the resolver. That is the same mechanism behind this
1784
+ // door's older surprise — an unknown key degrades the call to a read that
1785
+ // still answers ok:true.
1786
+ threadId: z.string().optional(),
1787
+ // WHO WROTE IT. Declared because a zod schema STRIPS an undeclared key
1788
+ // before the resolver ever sees it — the exact mechanism that left
1789
+ // `thread:append`'s already-implemented agent branch unreachable for every
1790
+ // gateway caller. `authorKind` stays UNDECLARED for the same reason it
1791
+ // does there: its value is a TRUST LABEL, and a caller naming its own is
1792
+ // the estate's most-repeated defect. The resolver derives the label and
1793
+ // honours this field only for an ATTESTED platform-staff caller; a bare
1794
+ // agent key already speaks as itself from ctx and needs no field.
1795
+ author: z.string().optional(),
1270
1796
  }).refine((v) => !!v.tid || !!v.slug, {
1271
1797
  message: "address the task by tid or slug",
1272
1798
  path: ["tid"],
@@ -1282,12 +1808,20 @@ export const RECEIVERS = {
1282
1808
  "tasks:subtask": receiver({
1283
1809
  receiver: "tasks:subtask",
1284
1810
  surfaces: { mcp: true },
1285
- summary: "Create a child task and hang it under its parent with `containment` in one write. Reuses create's insert path — there is no second task-insert anywhere",
1811
+ summary: "Create a child task COMPLETE — row, notes, tags, its `containment` edge to the parent and every `blockedBy` prerequisite — in ONE pipeline, so a child never appears claimable with an empty body or missing ordering",
1286
1812
  request: z.object({
1287
1813
  parent: z.string(),
1288
1814
  title: z.string(),
1289
1815
  tags: z.array(z.string()).optional(),
1290
1816
  assignee: z.string().optional(),
1817
+ // The prose goal, written at creation. A child that arrives with an empty body is
1818
+ // claimable-but-unbuildable; notes here close that window.
1819
+ notes: z.string().optional(),
1820
+ // One tid or a list (max 16) of prerequisites. Each writes a `blocks` edge in the
1821
+ // same pipeline as the row, so ordering is created at intake rather than remembered
1822
+ // later. Validated exactly as tasks:depend does: operate-role on each prerequisite
1823
+ // and the transitive cycle walk.
1824
+ blockedBy: z.union([z.string(), z.array(z.string())]).optional(),
1291
1825
  workspace: z.string().optional(),
1292
1826
  }),
1293
1827
  response: z.object({
@@ -1295,6 +1829,7 @@ export const RECEIVERS = {
1295
1829
  tid: z.string().optional(),
1296
1830
  parent: z.string().optional(),
1297
1831
  tags: z.array(z.string()).optional(),
1832
+ blockedBy: z.array(z.string()).optional(),
1298
1833
  error: z.string().optional(),
1299
1834
  }),
1300
1835
  effect: "ask", idempotent: false, auth: "member",
@@ -1390,7 +1925,14 @@ export const RECEIVERS = {
1390
1925
  tid: z.string(),
1391
1926
  ok: z.boolean(),
1392
1927
  actorId: z.string().optional(),
1393
- reason: z.enum(["unassigned", "human-assignee", "blocked", "forbidden", "fire_failed"]).optional(),
1928
+ // `unspecced` = the task has no Proof: row (factory/spec-gate.ts).
1929
+ // `spec_unreadable` = we could not READ the task to decide — an outage,
1930
+ // kept distinct from a verdict per factory-spec.md demand 6.
1931
+ reason: z.enum(["unassigned", "human-assignee", "blocked", "forbidden", "fire_failed", "unspecced", "spec_unreadable"]).optional(),
1932
+ /** Why the gate refused, in one human-readable line. */
1933
+ detail: z.string().optional(),
1934
+ /** Set when the task launched only on the spec gate's grandfather clause. */
1935
+ warning: z.string().optional(),
1394
1936
  })),
1395
1937
  }),
1396
1938
  effect: "ask", idempotent: false,
@@ -1755,10 +2297,12 @@ export const RECEIVERS = {
1755
2297
  request: z.object({
1756
2298
  limit: z.number().optional(), since: z.number().optional(),
1757
2299
  from: z.number().optional(), to: z.number().optional(),
2300
+ intent: z.string().optional(),
1758
2301
  }),
1759
2302
  response: z.array(z.object({
1760
2303
  id: z.string(), from: z.string(), to: z.string(), skill: z.string().optional(),
1761
2304
  outcome: z.enum(["success", "failure", "timeout"]), revenue: z.number(), ts: z.number(),
2305
+ intent: z.string().optional(),
1762
2306
  })),
1763
2307
  effect: "ask", cost: "free", idempotent: true,
1764
2308
  }),
@@ -1873,6 +2417,78 @@ export const RECEIVERS = {
1873
2417
  effect: "ask", cost: "free", idempotent: true,
1874
2418
  examples: [{ feature: "premium" }],
1875
2419
  }),
2420
+ // ════════════════════════════════════════════════════════════════════════
2421
+ // id: — one door for every address in the estate. `id:resolve` is a pure
2422
+ // projection over `one.ie/web/src/data/id-inventory.json`, the artifact
2423
+ // `.claude/scripts/id-inventory.mjs` writes and its `--check` regenerates in
2424
+ // memory to fail on drift. NOTHING HERE IS HAND-DECLARED: kind, provenance,
2425
+ // count, id_prefix, summary and source are each read off that file, so this
2426
+ // door cannot claim a population the generator never counted.
2427
+ //
2428
+ // Seven kinds — signal · skill · workflow · component · doc · function ·
2429
+ // thing. `thing` is the LOCKED dimension-3 word: a ref whose prefix is
2430
+ // `object:` is REFUSED, never silently rewritten, because a door that
2431
+ // rewrites teaches callers a name the substrate does not have. Functions
2432
+ // address as `fn:` (the estate's existing address — `fn:run`'s FN_MAP),
2433
+ // never `fun:`.
2434
+ // ════════════════════════════════════════════════════════════════════════
2435
+ "id:resolve": receiver({
2436
+ receiver: "id:resolve",
2437
+ summary: "Resolve one address to its metadata card — seven generated kinds behind one door, every field projected from the generated id inventory. `object:` is refused in favour of `thing`; a ref the inventory does not hold answers `unknown_ref` rather than a guess",
2438
+ request: z.object({
2439
+ // The grammar `TID_RE` already admits (resolvers/factory.ts:32): MINTED
2440
+ // `<type>:<24-hex>` or DERIVED `<type>:<scope>[:<n>][/<part>]`. A bare
2441
+ // receiver name is its own address — `factory:size` resolves as a signal,
2442
+ // not as a `factory` scope, because an exact registry name is matched
2443
+ // before any prefix is read (21 receiver names would collide otherwise).
2444
+ ref: z.string().min(1).max(128),
2445
+ }),
2446
+ // `factory:size`'s idiom deliberately: one flat schema, `ok: z.boolean()`,
2447
+ // everything else optional — never a discriminated union. A refusal is the
2448
+ // same shape as an answer, so a caller reads `ok` and nothing else.
2449
+ response: z.object({
2450
+ ok: z.boolean(),
2451
+ ref: z.string().optional(),
2452
+ kind: z.string().optional(),
2453
+ // Lexical, per trace.ts:26-31 — the last colon segment decides. Never
2454
+ // temporal: `attempt:<hex>:<n>` is DERIVED because its last segment is an
2455
+ // ordinal, which is the same answer `traceIdMintedAt` already gives.
2456
+ lane: z.enum(["minted", "derived"]).optional(),
2457
+ name: z.string().optional(),
2458
+ title: z.string().optional(),
2459
+ summary: z.string().optional(),
2460
+ source: z.string().optional(),
2461
+ // BARE, never composed — the inventory stores `fn`, and `fn:<name>` is
2462
+ // built at read time. null for `signal`: a receiver's name IS its address.
2463
+ id_prefix: z.string().nullable().optional(),
2464
+ // Read off the kind record, not asserted here. `generated` for the five
2465
+ // counted kinds; `graph` is what `workflow` and `thing` carry, and they
2466
+ // hold no entries, so they answer `unknown_ref` until the graph is walked.
2467
+ provenance: z.string().optional(),
2468
+ // The kind's population, or null where the inventory refused to count.
2469
+ // null is UNRUN, never zero.
2470
+ count: z.number().nullable().optional(),
2471
+ // The commit the inventory was generated at — the answer's receipt.
2472
+ generated_at: z.string().optional(),
2473
+ error: z.string().optional(),
2474
+ }),
2475
+ // `auth: "authenticated"` — C20, the auditor's second finding. This door has
2476
+ // no `auth:` field until now, and an ABSENT label is the one case
2477
+ // `authClassFor` answers 'open' for (bind-receiver.ts:119), while the doc
2478
+ // five lines above it says an unlisted label falls to 'authenticated'. So
2479
+ // the declaration and the default disagreed, and this receiver was on the
2480
+ // wrong side of it: what it projects IS the web's internals — every id,
2481
+ // summary, source path and population count in the estate, from one
2482
+ // uncredentialed POST. 320 of 335 receivers share the absent label; the
2483
+ // default is NOT inverted this cycle (that needs the census bind-receiver.ts
2484
+ // now logs), so this one says so explicitly instead of waiting for it.
2485
+ //
2486
+ // Costs the contract nothing: text/story.md:24's accept sends
2487
+ // GATEWAY_API_KEY, which `isVerifiedServiceCaller` attests, so the promise's
2488
+ // own check passes unchanged.
2489
+ effect: "ask", auth: "authenticated", cost: "free", idempotent: true,
2490
+ examples: [{ ref: "factory:size" }, { ref: "fn:uncovered" }],
2491
+ }),
1876
2492
  "plugins:list": receiver({
1877
2493
  receiver: "plugins:list",
1878
2494
  summary: "List all available ONE plugins with their feature key, price, and description",
@@ -2041,6 +2657,16 @@ export const RECEIVERS = {
2041
2657
  content: z.string(),
2042
2658
  ts: z.number().optional(),
2043
2659
  tags: z.array(z.string()).optional(),
2660
+ // The resolver implements this branch (messaging.ts) — an agent mirroring
2661
+ // its own reply back into the inbox writes role "assistant", which the
2662
+ // resolver turns into authorKind "agent". Undeclared, zod stripped it
2663
+ // before the resolver ever saw it, so the branch was unreachable for
2664
+ // EVERY gateway caller and every agent post landed as role "user" /
2665
+ // authorKind "customer". `authorKind` stays UNDECLARED deliberately: its
2666
+ // only reachable value is "staff", which is a caller naming its own
2667
+ // author (resolver-body-actorId-not-identity) and needs an attestation
2668
+ // decision, not a schema line.
2669
+ role: z.enum(["user", "assistant"]).optional(),
2044
2670
  }),
2045
2671
  response: z.object({
2046
2672
  ok: z.boolean(),
@@ -2283,6 +2909,55 @@ export const RECEIVERS = {
2283
2909
  }),
2284
2910
  effect: "ask", cost: "free", reversible: true, idempotent: false, auth: "member",
2285
2911
  }),
2912
+ // ── EVENTS (dim 5) — the analytics ingress ───────────────────────────────────
2913
+ // event:track — the write behind POST /api/events, the browser pixel's door.
2914
+ // PUBLIC by construction: the pixel runs on third-party pages with no session,
2915
+ // no key and no cookie the substrate controls, so an auth label here would
2916
+ // throw in bindReceiver's applyAuth and silently kill 46k events/30d.
2917
+ // Identity is never taken from this payload; the route attests the caller and
2918
+ // hands the resolver an already-enriched row.
2919
+ "event:track": receiver({
2920
+ receiver: "event:track",
2921
+ summary: "Record one analytics event (agent_events_warm) and broadcast it to the workspace AnalyticsRelay",
2922
+ request: z.object({
2923
+ id: z.string(),
2924
+ ts: z.number(),
2925
+ slug: z.string(),
2926
+ event: z.string(),
2927
+ source: z.string(),
2928
+ visitor_hash: z.string().optional().nullable(),
2929
+ agent_id: z.string().optional().nullable(),
2930
+ variant: z.string().optional().nullable(),
2931
+ thread_id: z.string().optional().nullable(),
2932
+ actor_id: z.string().optional().nullable(),
2933
+ actor_type: z.string().optional().nullable(),
2934
+ channel: z.string().optional().nullable(),
2935
+ campaign: z.string().optional().nullable(),
2936
+ link_id: z.string().optional().nullable(),
2937
+ referrer: z.string().optional().nullable(),
2938
+ user_agent_class: z.string().optional().nullable(),
2939
+ locale: z.string().optional().nullable(),
2940
+ payload: z.record(z.string(), z.unknown()).optional().nullable(),
2941
+ consent_state: z.string().optional().nullable(),
2942
+ region: z.string().optional().nullable(),
2943
+ tags: z.array(z.string()).optional().nullable(),
2944
+ }),
2945
+ response: z.object({ ok: z.boolean(), id: z.string().optional(), error: z.string().optional() }),
2946
+ examples: [
2947
+ {
2948
+ id: "01J8ZQ3K2P0000000000000000",
2949
+ ts: 1767225600000,
2950
+ slug: "one",
2951
+ event: "pageview",
2952
+ source: "web",
2953
+ visitor_hash: "d41d8cd98f00b204",
2954
+ consent_state: "granted",
2955
+ region: "IE",
2956
+ payload: { page: "/pricing" },
2957
+ },
2958
+ ],
2959
+ effect: "signal", cost: "free", reversible: false, idempotent: true, auth: "public",
2960
+ }),
2286
2961
  // ── FUNNELS (text/funnels-plan.md) — author + run funnel definitions ──────────
2287
2962
  // funnel:create — create a new funnel definition (draft).
2288
2963
  "funnel:create": receiver({
@@ -2737,6 +3412,9 @@ export const RECEIVERS = {
2737
3412
  actor: z.string(), chain: z.string(), address: z.string(), balance: z.string().nullable(),
2738
3413
  })).optional(),
2739
3414
  addresses: z.array(z.object({ actor: z.string(), chain: z.string(), address: z.string() })).optional(),
3415
+ // The per-agent PROJECTION of the on-chain ceiling (text/launch.md §0d).
3416
+ // Zod strips unknown keys, so a field absent here never reaches AgentCaps.
3417
+ agentCaps: z.array(z.record(z.string(), z.unknown())).optional(),
2740
3418
  delegated: z.array(z.record(z.string(), z.unknown())).optional(),
2741
3419
  error: z.string().optional(),
2742
3420
  }),
@@ -2761,6 +3439,37 @@ export const RECEIVERS = {
2761
3439
  effect: "ask", cost: "free", reversible: true, idempotent: true, settles: "none", auth: "member",
2762
3440
  examples: [{ limit: 20 }, { workspace: "acme", limit: 100 }],
2763
3441
  }),
3442
+ // wallet:transfer — INTERNAL credits only. TRANSFER, NEVER MINT: a
3443
+ // solvency-checked debit and a credit that sum to zero, so a compromised caller
3444
+ // can move credits around but cannot create them. Bounded by the payer's cap so
3445
+ // an agent cannot transfer its way past its ceiling. Deliberately NOT
3446
+ // `surfaces: { mcp: true }` — it never touches a chain and settles in the
3447
+ // ledger, so it declares `settles: "offchain"` and stays reversible by a
3448
+ // compensating transfer, unlike wallet:send.
3449
+ "wallet:transfer": receiver({
3450
+ receiver: "wallet:transfer",
3451
+ summary: "Move credits between workspaces inside the ledger — solvency-checked, cap-bounded, zero-sum. Never mints; never touches a chain",
3452
+ request: z.object({
3453
+ to: z.string().describe("Recipient workspace slug"),
3454
+ amount: z.number().describe("Credits; must be > 0 and an integer"),
3455
+ workspace: z.string().optional().describe("Payer workspace; falls back to `from`, then the attested caller"),
3456
+ from: z.string().optional().describe("Alias for `workspace` — the source the authority walk runs against"),
3457
+ transferId: z.string().optional().describe("Idempotency key; both legs share it as rail_ref"),
3458
+ memo: z.string().optional(),
3459
+ }),
3460
+ response: z.object({
3461
+ ok: z.boolean(),
3462
+ from: z.string().optional(),
3463
+ to: z.string().optional(),
3464
+ amount: z.number().optional(),
3465
+ transferId: z.string().optional(),
3466
+ error: z.string().optional().describe("forbidden | insufficient_credits | cap_exceeded | invalid_amount"),
3467
+ }),
3468
+ // Zero-sum inside the credit ledger — real value, settled OFFCHAIN (never on
3469
+ // chain), reversible by a compensating transfer. Idempotent on `transferId`.
3470
+ effect: "ask", cost: "free", reversible: true, idempotent: true, settles: "offchain", auth: "manage_workspace",
3471
+ examples: [{ to: "acme", amount: 500 }],
3472
+ }),
2764
3473
  // The caller signs and broadcasts the transfer CLIENT-SIDE and passes the
2765
3474
  // resulting `paymentTx`; this composes pay.one.ie's create → quote → claim and
2766
3475
  // never holds a key. It VERIFIES that an on-chain transfer settled, so it
@@ -3628,7 +4337,7 @@ export const RECEIVERS = {
3628
4337
  "workflow:step-stats": receiver({
3629
4338
  receiver: "workflow:step-stats",
3630
4339
  surfaces: { mcp: true },
3631
- summary: "Per-step run counts and latency for one workflow (aggregated over D1 workflow_run_event) — what the canvas prints on each step card",
4340
+ summary: "Per-step run counts, latency and last messages for one workflow (over D1 workflow_run_event) — what the canvas prints on each step card",
3632
4341
  request: z.object({ workflowId: z.string(), sinceMs: z.number().int().optional() }),
3633
4342
  response: z.object({
3634
4343
  stats: z.array(z.object({
@@ -3636,8 +4345,28 @@ export const RECEIVERS = {
3636
4345
  runs: z.number().int(),
3637
4346
  failures: z.number().int(),
3638
4347
  avgMs: z.number().nullable(),
4348
+ minMs: z.number().nullable(),
3639
4349
  maxMs: z.number().nullable(),
4350
+ /** Total ms this step has spent across every completed run. */
4351
+ totalMs: z.number(),
4352
+ /** This step's slice of the whole workflow's execution time, 0-1 — which
4353
+ * step is the bottleneck. null when nothing has completed yet (never 0:
4354
+ * no share of nothing is not "0% of the time"). */
4355
+ share: z.number().nullable(),
3640
4356
  lastAt: z.number().nullable(),
4357
+ // The step's last few events, newest first — what it SAID, not just how
4358
+ // often it ran. `text` is already truncated server-side; a card that wants
4359
+ // the whole payload opens the run monitor.
4360
+ messages: z.array(z.object({
4361
+ kind: z.string(),
4362
+ status: z.string().nullable(),
4363
+ latencyMs: z.number().nullable(),
4364
+ at: z.number(),
4365
+ text: z.string(),
4366
+ })),
4367
+ /** Recent completed latencies, OLDEST → NEWEST, for the card's sparkline.
4368
+ * Empty when the step has completed nothing recently. */
4369
+ series: z.array(z.number()),
3641
4370
  })),
3642
4371
  error: z.string().optional(),
3643
4372
  }),
@@ -4667,6 +5396,21 @@ export const RECEIVERS = {
4667
5396
  }),
4668
5397
  effect: "ask", auth: "public", cost: "free", reversible: false, idempotent: true,
4669
5398
  }),
5399
+ // ── health — the doctor. Handler in resolvers/health.ts ───────────────────
5400
+ "health:diagnose": receiver({
5401
+ receiver: "health:diagnose",
5402
+ summary: "Probe every deployed surface and the receiver failure rates D1 already records, then write the verdict back as weighted paths (mark on up, warn on down or on a sick receiver) so the next sweep starts from what the last one learned. Diagnoses only — it never restarts, redeploys or kills anything, because the remote half has no provably safe remedy. Fired by the health-tick cron trigger.",
5403
+ request: z.object({}),
5404
+ response: z.object({
5405
+ ok: z.boolean(),
5406
+ verdict: z.enum(["healthy", "degraded", "unhealthy"]).optional(),
5407
+ symptoms: z.array(z.string()).optional(),
5408
+ surfaces: z.array(z.unknown()).optional(),
5409
+ sick: z.array(z.unknown()).optional(),
5410
+ checkedAt: z.string().optional(),
5411
+ }),
5412
+ effect: "ask", auth: "public", cost: "free", reversible: false, idempotent: true,
5413
+ }),
4670
5414
  // ── seo — handlers in resolvers/seo.ts (C1 live; C2 async) ─────────────────
4671
5415
  "seo:backlinks-summary": receiver({
4672
5416
  receiver: "seo:backlinks-summary",
@@ -4722,6 +5466,50 @@ export const RECEIVERS = {
4722
5466
  effect: "ask", cost: "variable", reversible: true, idempotent: false, auth: "member",
4723
5467
  examples: [{ site_url: "https://one.ie/", start_date: "2026-01-01", end_date: "2026-06-01" }],
4724
5468
  }),
5469
+ // ── promise: — the contract a story signs (resolvers/promises.ts) ──────────
5470
+ // Declared here because a workflow step may only bind a REGISTERED name:
5471
+ // `story-intake` binds both, and campaign-ads-social.test.ts refuses a binding
5472
+ // no receiver implements. `promise:get` is deliberately NOT declared — nothing
5473
+ // binds it and this cycle registers only what a template reaches.
5474
+ "promise:make": receiver({
5475
+ receiver: "promise:make",
5476
+ summary: "State terms and exactly one proof observable — the terms FREEZE (sha-256 terms_hash, insert-once) and the promise enters state 'promised'. The maker is the attested caller, never a body field",
5477
+ request: z.object({
5478
+ slug: z.string().describe("Stable id for the promise, e.g. story:<origin>. [A-Za-z0-9_:/-], max 128"),
5479
+ terms: z.string().describe("What is promised — hashed verbatim into terms_hash and never rewritten"),
5480
+ proof: z.string().describe("The ONE observable that decides kept vs broken. A shell-shaped proof settles at PROVE via the CLI, never in the worker"),
5481
+ }),
5482
+ response: z.object({
5483
+ ok: z.boolean(),
5484
+ slug: z.string().optional(),
5485
+ state: z.literal("promised").optional(),
5486
+ terms_hash: z.string().optional(),
5487
+ error: z.string().optional(),
5488
+ }),
5489
+ effect: "ask", cost: "free", auth: "member",
5490
+ reversible: false, idempotent: true, settles: "none",
5491
+ examples: [{ slug: "story:demo", terms: "a mover in Austin tells her origin story on her own page", proof: "test -f one.ie/web/src/pages/story.astro" }],
5492
+ }),
5493
+ "promise:settle": receiver({
5494
+ receiver: "promise:settle",
5495
+ summary: "Close the loop on a promise — kept marks the path, broken warns it, and a terminal promise never re-settles. Only the PERSISTED maker may settle; the verdict is the maker's assertion, the resolver never runs the proof",
5496
+ request: z.object({
5497
+ slug: z.string(),
5498
+ kept: z.boolean().optional().describe("Defaults to true. false ⇒ settled-broken: resistance += 1, the path decays honestly"),
5499
+ composite: z.number().optional().describe("Rubric composite; scales the mark by 5× when kept"),
5500
+ }),
5501
+ response: z.object({
5502
+ ok: z.boolean(),
5503
+ slug: z.string().optional(),
5504
+ state: z.enum(["settled-kept", "settled-broken", "dissolved"]).optional(),
5505
+ strength: z.number().optional(),
5506
+ resistance: z.number().optional(),
5507
+ error: z.string().optional(),
5508
+ }),
5509
+ effect: "ask", cost: "free", auth: "member",
5510
+ reversible: false, idempotent: false, settles: "none",
5511
+ examples: [{ slug: "story:demo", kept: false }],
5512
+ }),
4725
5513
  };
4726
5514
  /**
4727
5515
  * RECIPES — the four agent journeys as typed, ordered receiver sequences (C6).