@oneie/sdk 0.15.0 → 0.16.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 (42) hide show
  1. package/README.md +2 -0
  2. package/dist/billing.d.ts +53 -0
  3. package/dist/billing.d.ts.map +1 -1
  4. package/dist/billing.js +36 -0
  5. package/dist/billing.js.map +1 -1
  6. package/dist/blocks.d.ts.map +1 -1
  7. package/dist/blocks.js +7 -1
  8. package/dist/blocks.js.map +1 -1
  9. package/dist/client.d.ts +32 -0
  10. package/dist/client.d.ts.map +1 -1
  11. package/dist/client.js +44 -0
  12. package/dist/client.js.map +1 -1
  13. package/dist/fn-allowlist.d.ts +1 -1
  14. package/dist/fn-allowlist.d.ts.map +1 -1
  15. package/dist/fn-allowlist.js +30 -0
  16. package/dist/fn-allowlist.js.map +1 -1
  17. package/dist/generated/fn-map.d.ts +2 -2
  18. package/dist/generated/fn-map.d.ts.map +1 -1
  19. package/dist/generated/fn-map.js +22 -4
  20. package/dist/generated/fn-map.js.map +1 -1
  21. package/dist/generated/schemas/index.d.ts +7 -7
  22. package/dist/generated/schemas.d.ts +4 -4
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/media.d.ts +29 -0
  28. package/dist/media.d.ts.map +1 -0
  29. package/dist/media.js +127 -0
  30. package/dist/media.js.map +1 -0
  31. package/dist/receivers.d.ts +512 -4
  32. package/dist/receivers.d.ts.map +1 -1
  33. package/dist/receivers.js +738 -7
  34. package/dist/receivers.js.map +1 -1
  35. package/dist/schemas.d.ts +2 -2
  36. package/dist/vault-file.d.ts +61 -0
  37. package/dist/vault-file.d.ts.map +1 -0
  38. package/dist/vault-file.js +127 -0
  39. package/dist/vault-file.js.map +1 -0
  40. package/dist/work-contract-grammar.json +16 -0
  41. package/package.json +9 -1
  42. package/dist/.build-fingerprint +0 -1
package/dist/receivers.js CHANGED
@@ -226,17 +226,45 @@ export const RECEIVERS = {
226
226
  request: z.object({ workspace: z.string() }),
227
227
  response: ok, effect: "ask", auth: "manage_workspace",
228
228
  }),
229
+ /**
230
+ * TWO PATHS, ONE RECEIVER — and the second one is why `slug`/`parent` exist.
231
+ *
232
+ * Bare (`name` + `type`, no `groupSlug`, no `parent`): unchanged legacy behaviour —
233
+ * a random gid in D1 `world_groups`. That is what a campaign or a collection
234
+ * is, and every existing caller stays on it byte for byte.
235
+ *
236
+ * Substrate (`groupSlug` + `parent`, BOTH required together): the real group unit —
237
+ * a TypeDB `group`, a `hierarchy` edge to the parent, an owner `membership`
238
+ * for the attested caller, and the D1 `owners` row — written by `createGroup`
239
+ * in `one.ie/web/src/lib/group-unit.ts`. Measured 2026-09-16: no shipped
240
+ * receiver could write a group with a PARENT and a chosen slug, so a personal
241
+ * group under a world root had to be typed by hand.
242
+ *
243
+ * The gid's SHAPE differs by path and that is the contract change callers
244
+ * notice: `generateTraceId()` on the bare path, deterministic `group:<slug>`
245
+ * on the substrate one.
246
+ *
247
+ * `parent` NAMES a target; it never authorizes one. The handler walks the
248
+ * caller's control of the parent workspace and refuses otherwise, and the
249
+ * owner it writes is the ATTESTED caller — never a body field.
250
+ */
229
251
  "world:create-group": receiver({
230
252
  receiver: "world:create-group",
231
253
  surfaces: { mcp: true },
232
- summary: "Create a group in the caller's workspace",
254
+ summary: "Create a group a D1 campaign/collection row, or (with groupSlug+parent) a substrate group under a parent the caller controls",
233
255
  request: z.object({
234
256
  name: z.string(),
235
257
  // "group" is the D1 group_type for campaigns/collections; absent from the TypeDB enum
236
- type: GroupEntitySchema.shape["group-type"].or(z.literal("group")),
258
+ type: GroupEntitySchema.shape["group-type"].or(z.literal("group"))
259
+ .describe("One of the nine group-type values in schema/one.tql, or the D1-only \"group\". The substrate path (groupSlug+parent) accepts ONLY the nine — TypeDB's @values constraint refuses anything else and that write would fail silently."),
260
+ groupSlug: z.string().optional()
261
+ .describe("Bare slug for a SUBSTRATE group; the gid is always `group:<groupSlug>`. Requires `parent` — a slug with no parent would mint an unparented root workspace and is refused. NOT named `slug`: on the service lane `/api/ask` reads `payload.slug ?? payload.workspace` as the workspace the caller NOMINATES for itself, so a field called `slug` here would silently re-identify the caller as the group it is trying to create."),
262
+ parent: z.string().optional()
263
+ .describe("gid of the parent group, e.g. \"group:one\". Requires `groupSlug`. It NAMES a target and never authorizes one: the caller must control the parent workspace or the call is refused."),
237
264
  tags: z.array(z.string()).optional(),
238
265
  }),
239
- response: z.object({ gid: z.string() }), effect: "ask", auth: "manage_groups",
266
+ response: z.object({ gid: z.string().describe("`group:<groupSlug>` on the substrate path; a random trace id on the bare D1 path") }),
267
+ effect: "ask", auth: "manage_groups",
240
268
  }),
241
269
  "world:update-group": receiver({
242
270
  receiver: "world:update-group",
@@ -376,6 +404,7 @@ export const RECEIVERS = {
376
404
  nickname: z.string().optional(),
377
405
  title: z.string().optional(),
378
406
  avatar: z.string().optional(),
407
+ cover: z.string().optional(),
379
408
  bio: z.string().optional(),
380
409
  // contact
381
410
  email: z.string().optional(),
@@ -974,8 +1003,23 @@ export const RECEIVERS = {
974
1003
  board: z.literal("marketplace").optional(),
975
1004
  slug: z.string().optional(),
976
1005
  workspace: z.string().optional(),
1006
+ // Rides through unchanged to buildMessage (string field, unaffected)
1007
+ // and is what lets an inbox card show a title instead of a bare tid.
1008
+ title: z.string().optional(),
1009
+ // The structured envelope (`web/src/lib/signal-meta.ts SignalMeta`) —
1010
+ // an object, so buildMessage's string/number-only flatten drops it from
1011
+ // the text body by construction. Declared loosely here (not the exact
1012
+ // shape) because this is the ONE cross-cutting schema every announce
1013
+ // caller reuses; a stricter zod object would have to be kept in lockstep
1014
+ // with the TS type by hand.
1015
+ meta: z.record(z.string(), z.unknown()).optional(),
1016
+ }),
1017
+ response: z.object({
1018
+ ok: z.boolean(),
1019
+ taskId: z.string().optional(),
1020
+ matched: z.number().optional().describe("REACH, NEVER DELIVERY. This is `actors.length` from the fan-out (`resolvers/subscriptions.ts:1245`) — how many staked actors the tags MATCHED, not how many received it, read it, or acted on it. Each matched actor got an inbox row; whether any of them moved the task is one `tasks:claim` away and this number cannot see it. A digest that quotes this as 'N people are on it' is reporting a fan-out as a delivery."),
1021
+ actors: z.array(z.string()).optional().describe("The actor ids the fan-out reached — the same reach `matched` counts, itemised. Reaching is not acting."),
977
1022
  }),
978
- response: z.object({ ok: z.boolean(), taskId: z.string().optional(), matched: z.number().optional(), actors: z.array(z.string()).optional() }),
979
1023
  effect: "ask",
980
1024
  }),
981
1025
  "tasks:mine": receiver({
@@ -1028,6 +1072,11 @@ export const RECEIVERS = {
1028
1072
  name: z.string(),
1029
1073
  tags: z.array(z.string()),
1030
1074
  weight: z.number(),
1075
+ // Shipped by the resolver (subscriptions.ts § tasks:everywhere) and undeclared
1076
+ // until now. The response check is WARN-only (bind-receiver.ts:247), so the
1077
+ // field arrived while the contract denied it — and every reader generated FROM
1078
+ // the contract (metaSchema, the MCP tool schema, OpenAPI) could not see it.
1079
+ priority: z.number().optional().describe("Task priority; the ranking input behind `weight`"),
1031
1080
  notes: z.string().optional(),
1032
1081
  contextDocs: z.array(z.string()).optional(),
1033
1082
  workspace: z.string(),
@@ -1039,6 +1088,10 @@ export const RECEIVERS = {
1039
1088
  offeredByMe: z.boolean(),
1040
1089
  closedAt: z.string().optional(),
1041
1090
  })),
1091
+ // A cap that reports itself (the /api/things standard). The old 200 clamp
1092
+ // was silent: a 595-row queue came back as 200 rows and read as complete.
1093
+ total: z.number().optional().describe("Visible rows before the limit was applied"),
1094
+ truncated: z.boolean().optional().describe("true = more rows exist than were returned; raise limit or use tasks:board"),
1042
1095
  }),
1043
1096
  effect: "ask", idempotent: true,
1044
1097
  }),
@@ -1321,6 +1374,63 @@ export const RECEIVERS = {
1321
1374
  // a dedupe. The RUN row dedupes (INSERT OR IGNORE); the events do not.
1322
1375
  effect: "ask", idempotent: false,
1323
1376
  }),
1377
+ // ── ehc: ───────────────────────────────────────────────────────────────────
1378
+ //
1379
+ // The first member of this namespace, and it sets the convention: an `ehc:`
1380
+ // receiver acts on ONE named child's plan, and the child's address comes from
1381
+ // the case registry (`one.ie/web/src/lib/ehc/address.ts`), never from a string
1382
+ // a caller typed.
1383
+ //
1384
+ // Read the summary as a limit, not a feature list. This door cannot record a
1385
+ // CYP's own words \u2014 it accepts no voice key, so every record it writes is a
1386
+ // proxy record and renders as MARKED INTERPRETATION naming the adult. A child
1387
+ // speaks through the page that minted their credential. An agent-class caller
1388
+ // is refused outright: Section A is what a child said or an adult's marked
1389
+ // account of what a child communicated, and a generated sentence is neither.
1390
+ "ehc:voice-record": receiver({
1391
+ receiver: "ehc:voice-record",
1392
+ summary: "Record what a child or young person communicated into Section A of their EHC plan, as MARKED INTERPRETATION attributed to the attested adult who recorded it. Authorship is derived from the credential and never from the payload; an agent-class caller is refused (machines do not speak for children); a case that is not synthetic is refused pending a data-residency decision (Article 9 special category data)",
1393
+ request: z.object({
1394
+ cyp: z.string(), // case id, e.g. 'cyp-005' \u2014 resolved through the registry
1395
+ body: z.string(), // exactly what was communicated. Never smoothed.
1396
+ proxyRole: z.string().optional(), // 'parent' | 'keyworker' | 'advocate' \u2014 shown to every reader
1397
+ prompt: z.string().optional(), // which portal question this answers
1398
+ }),
1399
+ response: z.object({
1400
+ ok: z.boolean(),
1401
+ id: z.string().optional(),
1402
+ provenance: z.string().optional(), // always 'proxy-observed' from this door
1403
+ saidAt: z.string().optional(),
1404
+ error: z.string().optional(), // 'not_found' | 'residency_undecided' | 'machines_do_not_speak_for_children' | 'no_voice' | 'empty' | 'too_long'
1405
+ detail: z.string().optional(),
1406
+ }),
1407
+ effect: "ask", cost: "free", reversible: false, idempotent: false, auth: "session",
1408
+ }),
1409
+ // The event a provision's `verification.closesOn` names (cases/cyp-001.ts H1).
1410
+ // A receiver cannot be per-child, so the child rides in `cyp` and the instance
1411
+ // name `ehc:<cyp>:meal-served` is what the event row carries. It records a
1412
+ // human tick that a meal was SERVED — never that one was ordered.
1413
+ "ehc:meal-served": receiver({
1414
+ receiver: "ehc:meal-served",
1415
+ summary: "A named adult ticks that a child was served a meal. Writes one event row named by the provision's closesOn (e.g. ehc:cyp-001:meal-served) attributed to the attested caller, never a payload field; refuses an agent-class caller, an unknown case, a non-synthetic case, and a case with no meal provision",
1416
+ request: z.object({
1417
+ cyp: z.string(), // case id, e.g. 'cyp-001' — resolved through the registry
1418
+ meal: z.enum(["breakfast", "lunch", "tea"]),
1419
+ servedOn: z.string().optional(), // YYYY-MM-DD; defaults to today (UTC)
1420
+ }),
1421
+ response: z.object({
1422
+ ok: z.boolean(),
1423
+ id: z.string().optional(),
1424
+ event: z.string().optional(),
1425
+ provision: z.string().optional(),
1426
+ meal: z.string().optional(),
1427
+ servedOn: z.string().optional(),
1428
+ adult: z.string().optional(),
1429
+ error: z.string().optional(),
1430
+ detail: z.string().optional(),
1431
+ }),
1432
+ effect: "signal", cost: "free", reversible: false, idempotent: false, auth: "session",
1433
+ }),
1324
1434
  "story:chain": receiver({
1325
1435
  receiver: "story:chain",
1326
1436
  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",
@@ -1472,6 +1582,11 @@ export const RECEIVERS = {
1472
1582
  // task. Without it the task is an orphan: on the board, unreachable from any plan.
1473
1583
  // Refused unless the caller has operate access to the parent.
1474
1584
  parent: z.string().optional(),
1585
+ // 0..1, the same fraction tasks:priority stores. Added 2026-09-14: without it this
1586
+ // door could not express a priority, so every row it filed was invisible to the only
1587
+ // sort the board pages by — 407 of 730 on the day it was added. Omit to leave the
1588
+ // attribute ABSENT (never judged), which is distinct from 0 (judged unimportant).
1589
+ priority: z.number().min(0).max(1).optional(),
1475
1590
  // The viewed /u/<slug> workspace to file the task under. Honored only when the
1476
1591
  // caller is authorized for it (attested staff or owner-tree control); otherwise
1477
1592
  // the resolver falls back to the caller's own slug. Reconciles the create tag
@@ -1851,6 +1966,232 @@ export const RECEIVERS = {
1851
1966
  }),
1852
1967
  effect: "ask", idempotent: true, auth: "member",
1853
1968
  }),
1969
+ // tasks:board — the ONE-REQUEST read of a whole board, with the context a planner needs.
1970
+ //
1971
+ // WHY. Measured 2026-09-13: an agent asked for the board, hit a 200-row page
1972
+ // (the MCP tool's documented cap; `tasks:everywhere` clamps to 200), paged by six
1973
+ // tags, found 353 more rows by NAME ONLY, and never learned the real total.
1974
+ // `tasks:list` has no cap but refuses without a `tag`, so the unfiltered
1975
+ // question — "every task in this group" — had no door at all.
1976
+ //
1977
+ // CONTRACT. (1) `total` is the count that matched the filters BEFORE paging, and
1978
+ // is never an estimate; a partial page carries `nextCursor`, and any budget the
1979
+ // underlying read could not honour is named in `truncated` — a short answer
1980
+ // always says it is short. (2) `summary` is computed over ALL matched rows, not
1981
+ // the page, so a planner can reason about 2,000 tasks while reading 0 of them.
1982
+ // (3) Rows are compact by default; `include` widens them. (4) Served from the
1983
+ // edge snapshot (KV memo of the board, `asOf` stamps it) — never a live TypeDB
1984
+ // read per call (CLAUDE.md § The brain and the edge). `fresh: true` asks for a
1985
+ // rebuild and is honoured at most once per memo window.
1986
+ "tasks:board": receiver({
1987
+ receiver: "tasks:board",
1988
+ surfaces: { mcp: true },
1989
+ summary: "The whole task board in ONE request: every task in a group (or its whole subtree with scope:'tree'), filtered in RAM, with `total` before paging, a `nextCursor` when there is more, `truncated` naming any budget that bit, and a `summary` over ALL matched rows — counts by status/tag/assignee/workspace, the ready set, the blocked set, overdue, unassigned, orphans, no-notes. Use this to plan; use tasks:bulk to act on what it shows.",
1990
+ request: z.object({
1991
+ workspace: z.string().optional().describe("Group slug. Omit for your own. Honoured only if you may read it."),
1992
+ scope: z.enum(["own", "tree"]).optional().describe("tree = this group AND every descendant group (the CEO / agency lens). Default own."),
1993
+ status: z.union([z.string(), z.array(z.string())]).optional()
1994
+ .describe("open | blocked | picked | done | verified | failed | dissolved, one or many. 'active' = open+blocked+picked (the default). 'all' = every status."),
1995
+ tags: z.array(z.string()).optional().describe("Row must carry EVERY tag (AND)."),
1996
+ anyTags: z.array(z.string()).optional().describe("Row must carry AT LEAST ONE of these tags (OR)."),
1997
+ assignee: z.string().optional().describe("Actor slug. '' = unassigned only."),
1998
+ parent: z.string().optional().describe("Only direct children of this task id. '' = top-level rows (no parent)."),
1999
+ search: z.string().optional().describe("Case-insensitive substring on the task name."),
2000
+ ready: z.boolean().optional().describe("true = only rows claimable now (open, and every blocker closed). false = only rows that are NOT claimable now (not open, or at least one blocker still open). Omit for both."),
2001
+ include: z.array(z.enum(["notes", "graph", "dates", "thread"])).optional()
2002
+ .describe("Widen each row. notes = the prose goal, CLIPPED to the first 2000 chars per row (read the whole text one task at a time); graph = children + blocks (the reverse edges); dates = startAt/createdAt/closedAt; thread = the task's inbox conversation (id, comment count, last message). Compact rows already carry parent, blockedBy, dueAt."),
2003
+ view: z.enum(["rows", "summary", "both"]).optional().describe("summary = counts and sets only, no rows (cheapest way to see a 2,000-row board). Default both."),
2004
+ sort: z.enum(["priority", "due-at", "updated-at", "created-at", "name"]).optional().describe("Default priority (desc)."),
2005
+ dir: z.enum(["asc", "desc"]).optional(),
2006
+ limit: z.number().int().min(1).max(2000).optional().describe("Rows per page. Default 500, max 2000."),
2007
+ cursor: z.string().optional().describe("The nextCursor from the previous page."),
2008
+ fresh: z.boolean().optional().describe("Rebuild the snapshot instead of reading the memo. Rate-limited to once per memo window."),
2009
+ }),
2010
+ response: z.object({
2011
+ ok: z.boolean(),
2012
+ workspace: z.string().optional(),
2013
+ workspaces: z.array(z.string()).optional().describe("Every group the rows were read from (scope:'tree')."),
2014
+ asOf: z.string().optional().describe("ISO time the snapshot was built. Rows are at most this stale."),
2015
+ cached: z.boolean().optional(),
2016
+ total: z.number().optional().describe("Rows matching the filters, before paging. Exact — UNLESS `truncated.rows` is present, in which case the snapshot itself was capped and total is a floor."),
2017
+ returned: z.number().optional(),
2018
+ nextCursor: z.string().optional().describe("Present iff more rows match. Absent = you have them all."),
2019
+ truncated: z.object({
2020
+ rows: z.number().optional().describe("The snapshot hit its row budget; this many unique rows were read. total is a floor."),
2021
+ edges: z.boolean().optional().describe("parent/blockedBy edges were capped — a row may read as unblocked or orphaned when it is not."),
2022
+ tags: z.number().optional().describe("This many returned rows may be missing tags."),
2023
+ }).optional().describe("Absent = nothing was cut. Present = say so before you plan on it."),
2024
+ summary: z.object({
2025
+ byStatus: z.record(z.string(), z.number()),
2026
+ byTag: z.record(z.string(), z.number()).describe("Bare tags only (no workspace:/slug:/@ namespaces), top 100 by count."),
2027
+ byAssignee: z.record(z.string(), z.number()).describe("'' key = unassigned."),
2028
+ byWorkspace: z.record(z.string(), z.number()),
2029
+ ready: z.number().describe("Open with every blocker closed — claimable now."),
2030
+ blocked: z.number().describe("Has at least one open blocker, or status blocked."),
2031
+ overdue: z.number(),
2032
+ unassigned: z.number(),
2033
+ orphans: z.number().describe("No parent — unreachable from any plan's containment walk."),
2034
+ noNotes: z.number().describe("No prose goal — the rows a puller cannot act on."),
2035
+ readyIds: z.array(z.string()).describe("Top 50 ready rows by priority."),
2036
+ blockedIds: z.array(z.string()).describe("Top 50 blocked rows by priority."),
2037
+ }).optional(),
2038
+ tasks: z.array(z.object({
2039
+ tid: z.string(),
2040
+ name: z.string(),
2041
+ status: z.string(),
2042
+ priority: z.number(),
2043
+ tags: z.array(z.string()).describe("Bare tags; workspace:/@assignee are lifted into their own fields."),
2044
+ assignee: z.string().optional(),
2045
+ workspace: z.string(),
2046
+ parent: z.string().optional(),
2047
+ blockedBy: z.array(z.string()).optional(),
2048
+ openBlockers: z.number().optional().describe("How many of blockedBy are not yet done/verified/dissolved."),
2049
+ ready: z.boolean(),
2050
+ dueAt: z.string().optional(),
2051
+ overdue: z.boolean().optional(),
2052
+ // include: dates
2053
+ startAt: z.string().optional(),
2054
+ createdAt: z.string().optional(),
2055
+ closedAt: z.string().optional(),
2056
+ // include: notes
2057
+ notes: z.string().optional(),
2058
+ // include: graph
2059
+ children: z.array(z.string()).optional(),
2060
+ blocks: z.array(z.string()).optional(),
2061
+ // include: thread — the same D1 conversation tasks:comment writes and /in lists
2062
+ thread: z.object({
2063
+ threadId: z.string().optional().describe("The inbox thread id; absent = nobody has commented yet."),
2064
+ comments: z.number(),
2065
+ lastAt: z.string().optional(),
2066
+ lastAuthor: z.string().optional(),
2067
+ lastBody: z.string().optional().describe("First 280 chars of the latest message."),
2068
+ }).optional(),
2069
+ })).optional(),
2070
+ error: z.string().optional(),
2071
+ }),
2072
+ effect: "ask", idempotent: true, auth: "member",
2073
+ examples: [
2074
+ { workspace: "one", view: "summary" },
2075
+ { workspace: "one", scope: "tree", status: "active", ready: true, limit: 100 },
2076
+ { workspace: "one", anyTags: ["launch", "story"], include: ["notes", "graph"] },
2077
+ ],
2078
+ }),
2079
+ // tasks:bulk — the ONE-REQUEST door for N task mutations.
2080
+ //
2081
+ // Every other task write here takes a single `tid`, so refining a 595-row board
2082
+ // (measured, workspace `one`, 2026-09-11) costs 595+ round trips. That is the
2083
+ // reason the board has never been refined. This takes up to 25 rows and reports
2084
+ // a receipt PER ROW — never a single top-level ok that hides a partial run.
2085
+ //
2086
+ // It reimplements no write: the resolver loops and calls the existing
2087
+ // single-tid resolvers with the SAME attested ctx, so authority, the status
2088
+ // gates and the D1 tag mirror are all inherited rather than re-derived. The
2089
+ // `workspace` field (envelope or per row) is a REQUEST, exactly as on every
2090
+ // single-tid write — effectiveWorkspace downgrades it when the caller is
2091
+ // neither staff nor in control of it.
2092
+ "tasks:bulk": receiver({
2093
+ receiver: "tasks:bulk",
2094
+ surfaces: { mcp: true },
2095
+ summary: "Plan and act on many tasks in ONE request. Three modes, combinable: `creates` files up to 25 new tasks (with `ref` handles so rows in the same call can parent/block each other — a whole plan tree in one call); `edits` changes up to 25 named rows (title/status/priority/notes/assignee/tags/dates/dependencies/parent/comment — `parent` MOVES a row under another, the one door that writes `containment` after birth, and an empty string makes it a root again); `where`+`set` applies ONE edit to every row a tasks:board filter matches (dryRun defaults TRUE — the preview returns the matched ROWS themselves in `rows`, not just their tids, so you read the ≤25 rows you are about to change and then re-send the identical call with dryRun:false; applies 25 per call and returns `nextCursor` to continue). Returns a receipt PER ROW (ok, per-field reason, retryable) plus total/attempted/applied/failed — a row never attempted says so by name, so a partial run can never read as a success.",
2096
+ request: z.object({
2097
+ edits: z.array(z.object({
2098
+ tid: z.string(),
2099
+ title: z.string().optional(),
2100
+ status: z.string().optional().describe("open | blocked | picked | done | verified | failed | dissolved"),
2101
+ priority: z.number().optional().describe("1-100 slider, same scale as tasks:priority"),
2102
+ notes: z.string().optional().describe("Prose goal; empty string clears"),
2103
+ parent: z.string().optional().describe("MOVE this row under another — the only door that writes a `containment` edge AFTER birth (tasks:create and tasks:subtask write one only at birth, which is why a story could never be made a sub-story of another). An existing task id, or a `ref` created EARLIER in `creates`. EMPTY STRING CLEARS: the row becomes a root again, and clearing a row that already had no parent is a silent success. Refused by name, never silently: `cycle` if the new parent is this row or any row beneath it (walked transitively, and a walk that could not FINISH answers `undetermined` and is refused — never guessed at); `not_found` if the parent does not exist, OR the caller lacks operate access to it, OR the row's CURRENT parent is one the caller cannot operate (detaching changes that tree too) — the same rule, and the same word, tasks:create's `parent` uses; `cross_workspace` if the two ends live in different workspaces and the caller is not staff. CLAIM-GATE CONSEQUENCE — this is not only a tree edit: `tasks:claim` refuses `picked` while an OPEN `containment` child exists, so hanging an open row under a claimable parent makes that parent UNCLAIMABLE, and moving the last open child away makes it claimable again. Deliberately absent from `set`: a filter-driven mass re-parent moves whole subtrees at once — and multiplies that claim-gate consequence by the match count — so this is per-row only."),
2104
+ assignee: z.string().optional().describe("Actor slug; empty string unassigns"),
2105
+ addTags: z.array(z.string()).optional().describe("Bare words only — no ':' and no leading '@'"),
2106
+ removeTags: z.array(z.string()).optional(),
2107
+ dueAt: z.string().nullable().optional().describe("ISO date; null or '' clears. Same semantics as tasks:schedule"),
2108
+ startAt: z.string().nullable().optional(),
2109
+ addBlockedBy: z.array(z.string()).optional().describe("Task ids (or `ref`s from `creates`) this row must wait for — tasks:depend per id"),
2110
+ removeBlockedBy: z.array(z.string()).optional().describe("tasks:undepend per id"),
2111
+ comment: z.string().optional().describe("Post to the task's inbox thread (tasks:comment) — say WHY you changed it, so the humans watching the thread see the decision"),
2112
+ workspace: z.string().optional().describe("Per-row override of the envelope workspace. A request, never a grant."),
2113
+ })).max(25).optional(),
2114
+ creates: z.array(z.object({
2115
+ ref: z.string().optional().describe("A local handle, e.g. 'a'. Other rows in THIS call may name it in parent/blockedBy/addBlockedBy; the receipt maps ref → tid."),
2116
+ title: z.string(),
2117
+ notes: z.string().optional(),
2118
+ tags: z.array(z.string()).optional(),
2119
+ assignee: z.string().optional(),
2120
+ priority: z.number().optional(),
2121
+ parent: z.string().optional().describe("Existing task id or a ref created EARLIER in this array"),
2122
+ blockedBy: z.array(z.string()).optional().describe("Existing task ids or refs created earlier in this array"),
2123
+ dueAt: z.string().optional(),
2124
+ workspace: z.string().optional(),
2125
+ })).max(25).optional(),
2126
+ where: z.object({
2127
+ status: z.union([z.string(), z.array(z.string())]).optional(),
2128
+ tags: z.array(z.string()).optional(),
2129
+ anyTags: z.array(z.string()).optional(),
2130
+ assignee: z.string().optional(),
2131
+ parent: z.string().optional(),
2132
+ search: z.string().optional(),
2133
+ ready: z.boolean().optional(),
2134
+ scope: z.enum(["own", "tree"]).optional(),
2135
+ }).optional().describe("A tasks:board filter. Requires `set`. Matches are read from the same snapshot tasks:board serves."),
2136
+ set: z.object({
2137
+ status: z.string().optional(),
2138
+ priority: z.number().optional(),
2139
+ assignee: z.string().optional(),
2140
+ addTags: z.array(z.string()).optional(),
2141
+ removeTags: z.array(z.string()).optional(),
2142
+ dueAt: z.string().nullable().optional(),
2143
+ comment: z.string().optional(),
2144
+ }).optional().describe("The ONE edit applied to every `where` match. title/notes are deliberately absent — those are per-row. TWO ROUTES, AND THIS IS THE NARROWER ONE. `assignee` here is the DIRECT route: you decide the owner, for work whose owner is not in doubt. The DEFAULT is the other route — `tasks:announce` (or `signal(\"world\", {tags})`) walks the weighted tag→receiver paths to whoever STAKED those tags, marks the delivery, and the next route is smarter for it; the CEO is an observer there, not a bottleneck. Prefer the stake route and keep this one for the unambiguous case. And when you read the announce back: its `matched` is reach, never delivery."),
2145
+ dryRun: z.boolean().optional().describe("For `where`: defaults TRUE. Pass false to apply. Ignored by edits/creates."),
2146
+ cursor: z.string().optional().describe("For `where`: the nextCursor from the previous call"),
2147
+ workspace: z.string().optional().describe("Workspace to act in, for every row. Honoured only if you are staff or control it."),
2148
+ }).refine((v) => (v.edits?.length ?? 0) + (v.creates?.length ?? 0) > 0 || (!!v.where && !!v.set), {
2149
+ message: "send edits, creates, or where+set",
2150
+ path: ["edits"],
2151
+ }).refine((v) => !v.where === !v.set, { message: "where and set go together", path: ["where"] }),
2152
+ response: z.object({
2153
+ ok: z.boolean().describe("The CALL's outcome, not the rows'. Read applied/failed."),
2154
+ total: z.number().optional(),
2155
+ attempted: z.number().optional(),
2156
+ applied: z.number().optional(),
2157
+ failed: z.number().optional(),
2158
+ notAttempted: z.number().optional(),
2159
+ error: z.string().optional(),
2160
+ detail: z.string().optional(),
2161
+ created: z.record(z.string(), z.string()).optional().describe("ref → tid for every `creates` row that landed"),
2162
+ matched: z.number().optional().describe("`where`: rows the filter matched in total, across all pages. EXACT — unless `truncated` is present, and then it is a FLOOR. Quote it as 'at least N'."),
2163
+ truncated: z.object({
2164
+ rows: z.number().optional().describe("The board snapshot hit its row budget; this many unique rows were read. `matched` is a FLOOR — rows exist that the filter never saw, so the set you are previewing is a subset of the set you asked for."),
2165
+ edges: z.boolean().optional().describe("The dependency branch was capped. `where:{ready:true}` is computed from `blockedBy`, so a row can match as ready while it is in fact blocked."),
2166
+ tags: z.number().optional().describe("The tag branch was capped; this many rows have uncertain tags. A `tags`/`anyTags` filter may have MISSED rows, and the `tags` on the rows returned above may be short."),
2167
+ }).optional().describe("`where`: the budget that bit, named — the same word and the same shape tasks:board serves, because it is the same snapshot. Three budgets, independent, each key present only when THAT one bit. ABSENT MEANS NOTHING WAS CUT — absent and `false` are different answers, so branch on the key's presence and never on its value. This is the field to read rather than the prose in `detail`: `detail` is for a human reading a log, and on an aborted run it is overwritten by the abort reason, while this survives. A receiver that caps, pages or samples names the budget that bit, in the response, every time."),
2168
+ matchedIds: z.array(z.string()).optional().describe("`where`: the tids this call covers (the ones it would apply, on dryRun)"),
2169
+ rows: z.array(z.record(z.string(), z.unknown())).optional().describe("`where`, DRY RUN ONLY: the matched rows THEMSELVES — `matchedIds` with its contents attached. Same rows, same order (rows[i].tid === matchedIds[i], element for element), same shape tasks:board serves: tid · name · status · priority · tags · assignee · workspace · parent · blockedBy · dueAt · startAt · createdAt · closedAt · updatedAt · notes. This is what makes the dry run a READ you can judge instead of a count you must take on faith: you see the ≤25 rows you are about to change — their owners, their blockers, their tags — and then re-send the IDENTICAL call with dryRun:false. One filter expression, not two that can disagree about what the set is. Costs nothing: these are the rows the match was computed from, already in hand. ABSENT on an apply — `results` carries a receipt per tid there, and a second copy of the rows is the larger answer to the smaller question. TWO TRAPS. (1) `tags` is the BARE lens — namespaced words (`plan:…`, `cycle:…`, `workspace:…`) and the `@slug` assignee tag are stripped, and `assignee`/`workspace` are DERIVED from the stripped ones. `tags: []` therefore means NO ROUTING WORDS, never 'no tags on this row': measured 2026-09-15, rows carrying six tags each report `tags: []`. (2) A row here is at most `asOf` stale (the 60s board memo) and describes the row BEFORE `set` — never read it back as the result of the write."),
2170
+ dryRun: z.boolean().optional(),
2171
+ nextCursor: z.string().optional().describe("`where`: present iff more matches remain — resend the same call with this cursor"),
2172
+ results: z.array(z.object({
2173
+ tid: z.string(),
2174
+ ref: z.string().optional(),
2175
+ ok: z.boolean(),
2176
+ reason: z.string().optional(),
2177
+ retryable: z.boolean().optional(),
2178
+ fields: z.array(z.object({
2179
+ field: z.string(),
2180
+ ok: z.boolean(),
2181
+ reason: z.string().optional(),
2182
+ detail: z.string().optional(),
2183
+ retryable: z.boolean().optional(),
2184
+ })).optional(),
2185
+ })).optional(),
2186
+ }),
2187
+ effect: "ask", idempotent: false, auth: "member",
2188
+ examples: [
2189
+ { edits: [{ tid: "task:abc", status: "dissolved", comment: "Superseded by task:def" }, { tid: "task:def", notes: "Done when X", addTags: ["planning"] }], workspace: "one" },
2190
+ { creates: [{ ref: "plan", title: "Launch the pricing page" }, { ref: "copy", title: "Write pricing copy", parent: "plan" }, { title: "Ship pricing page", parent: "plan", blockedBy: ["copy"] }], workspace: "one" },
2191
+ { where: { status: "open", tags: ["launch"], assignee: "" }, set: { assignee: "cmo", comment: "Routing unowned launch work to marketing" }, dryRun: true, workspace: "one" },
2192
+ { creates: [{ ref: "arc", title: "The story we are actually telling", tags: ["story"] }], edits: [{ tid: "task:oldstory", parent: "arc", comment: "Nested under the main story" }, { tid: "task:looseplan", parent: "" }], workspace: "one" },
2193
+ ],
2194
+ }),
1854
2195
  "tasks:follow": receiver({
1855
2196
  receiver: "tasks:follow",
1856
2197
  surfaces: { mcp: true },
@@ -2667,6 +3008,10 @@ export const RECEIVERS = {
2667
3008
  // author (resolver-body-actorId-not-identity) and needs an attestation
2668
3009
  // decision, not a schema line.
2669
3010
  role: z.enum(["user", "assistant"]).optional(),
3011
+ // The structured envelope forwarded from `/signal/:group` — see
3012
+ // `web/src/lib/signal-meta.ts SignalMeta`. Loose on purpose (see
3013
+ // tasks:announce's `meta` field for why).
3014
+ meta: z.record(z.string(), z.unknown()).optional(),
2670
3015
  }),
2671
3016
  response: z.object({
2672
3017
  ok: z.boolean(),
@@ -2879,6 +3224,7 @@ export const RECEIVERS = {
2879
3224
  response: z.object({
2880
3225
  ok: z.boolean(),
2881
3226
  delivered: z.string().optional(), // 'web' | 'telegram' | 'discord' | 'failed'
3227
+ deliveryError: z.string().optional(), // present iff delivered==='failed' — the platform's own words; the reply IS still persisted
2882
3228
  kind: z.string().optional(),
2883
3229
  error: z.string().optional(),
2884
3230
  }),
@@ -3659,7 +4005,14 @@ export const RECEIVERS = {
3659
4005
  surfaces: { mcp: true },
3660
4006
  summary: "List a workspace's pages with status",
3661
4007
  request: z.object({ slug: z.string() }),
3662
- response: z.object({ pages: z.array(z.object({ slug: z.string(), title: z.string(), status: z.string() })) }),
4008
+ response: z.object({ pages: z.array(z.object({
4009
+ slug: z.string(),
4010
+ title: z.string(),
4011
+ status: z.string(),
4012
+ url: z.string().optional().describe("The page's public path, /p/<slug>"),
4013
+ updated_at: z.number().optional().describe("Last modified, epoch ms"),
4014
+ views: z.number().nullable().optional().describe("Pageviews from the analytics pixel. NULL means no pageview row exists — render it as '—', NOT as 0. A page live for five minutes with no visitors and a page nothing is measuring must not read the same."),
4015
+ })) }),
3663
4016
  effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
3664
4017
  }),
3665
4018
  "pages:publish": receiver({
@@ -3676,6 +4029,55 @@ export const RECEIVERS = {
3676
4029
  response: z.object({ ok: z.boolean(), published: z.number().optional(), error: z.string().optional() }),
3677
4030
  effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
3678
4031
  }),
4032
+ // WHY THIS IS DECLARED. `pages:delete` shipped as a resolver with no registry
4033
+ // row, so it reached the handler only through the undeclared-receiver escape
4034
+ // hatch — no edge zod parsing, no auth floor. Its two live callers send
4035
+ // `{slug, page}` (`scripts/demo-instantiate-movers.ts`) and
4036
+ // `{slug, actorId, page}` (`channels/src/tools/pages.ts`). `actorId` is NOT
4037
+ // declared here on purpose: `pagesDenyReason` authorizes off `ctx` alone and
4038
+ // has never read it, so declaring it would publish a field nothing consumes
4039
+ // and invite a caller to think it authorizes something.
4040
+ //
4041
+ // `auth: "member"` matches every sibling in this family. A tighter floor
4042
+ // reasoned out fresh would break the channels tool, and the break would look
4043
+ // like a bug in whatever called it.
4044
+ "pages:delete": receiver({
4045
+ receiver: "pages:delete",
4046
+ summary: "Permanently delete a workspace page. NOT REVERSIBLE — pages:restore only UPDATEs an existing row and cannot re-insert a deleted one, so nothing that ships can undo this.",
4047
+ request: z.object({ slug: z.string(), page: z.string() }),
4048
+ response: z.object({ ok: z.boolean(), slug: z.string().optional(), title: z.string().optional(), error: z.string().optional() }),
4049
+ effect: "ask", cost: "free", reversible: false, idempotent: true, auth: "member",
4050
+ }),
4051
+ "pages:unpublish": receiver({
4052
+ receiver: "pages:unpublish",
4053
+ summary: "Take a published page back to draft — the inverse of pages:publish. Writes no version snapshot (no content changes) and leaves published_at as the record of when it last went live.",
4054
+ request: z.object({ slug: z.string(), page: z.string() }),
4055
+ response: z.object({ ok: z.boolean(), slug: z.string().optional(), title: z.string().optional(), url: z.string().optional(), status: z.string().optional(), error: z.string().optional() }),
4056
+ effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
4057
+ }),
4058
+ "pages:bulk": receiver({
4059
+ receiver: "pages:bulk",
4060
+ summary: "Apply ONE op (publish | unpublish | delete) to an EXPLICIT list of pages — one authority walk, one round trip, a per-row answer.",
4061
+ request: z.object({
4062
+ slug: z.string(),
4063
+ op: z.enum(["publish", "unpublish", "delete"]),
4064
+ pages: z.array(z.string()).min(1).max(100).describe("Page slugs, REQUIRED and non-empty. There is deliberately no omitted-means-all case: pages:publish-pack has one, and an implicit whole-workspace scope is exactly what a selection UI must never be able to send. Over 100 is refused BY NAME (`too_many:N>100`), never truncated."),
4065
+ }),
4066
+ response: z.object({
4067
+ ok: z.boolean().describe("Describes THE CALL, not the rows. A run where every row failed still answers ok:true — read `changed` and `failed`, never this, to report what happened."),
4068
+ op: z.string().optional(),
4069
+ requested: z.number().optional().describe("Rows attempted, after de-duplication."),
4070
+ changed: z.number().optional().describe("Rows the op actually changed. This is the number to quote."),
4071
+ failed: z.number().optional().describe("requested - changed. Present even when 0, so a caller cannot miss it."),
4072
+ results: z.array(z.object({
4073
+ slug: z.string(), ok: z.boolean(), title: z.string().optional(), error: z.string().optional(),
4074
+ })).optional().describe("Per-row outcome, in the order attempted. A failed row names its own reason (`not_found`, `no_db`)."),
4075
+ error: z.string().optional(),
4076
+ }),
4077
+ // `reversible: false` because delete is one of the three ops and the
4078
+ // envelope cannot be conditional. publish/unpublish are each other's undo.
4079
+ effect: "ask", cost: "free", reversible: false, idempotent: true, auth: "member",
4080
+ }),
3679
4081
  "pages:fill-pack": receiver({
3680
4082
  receiver: "pages:fill-pack",
3681
4083
  summary: "Fill a pack's instantiated pages with real business facts — one call, still drafts. Never fabricates a testimonial; pricing derives from one rate, not typed per page.",
@@ -4017,7 +4419,17 @@ export const RECEIVERS = {
4017
4419
  receiver: "web:crawl",
4018
4420
  summary: "Fetch a URL and extract domain, name, description, and readable text (public)",
4019
4421
  request: z.object({ url: z.string() }),
4020
- response: z.object({ ok: z.boolean(), url: z.string().optional(), domain: z.string().optional(), name: z.string().optional(), description: z.string().optional(), text: z.string().optional(), error: z.string().optional() }),
4422
+ response: z.object({
4423
+ ok: z.boolean(),
4424
+ url: z.string().optional().describe("The URL that ANSWERED, not the one asked for — a crawl of an apex that 301s to www reports the www URL here"),
4425
+ domain: z.string().optional(),
4426
+ name: z.string().optional(),
4427
+ description: z.string().optional(),
4428
+ text: z.string().optional(),
4429
+ error: z.string().optional().describe("NAMED, never a single catch-all. invalid_url · https_required · dns_unresolved · blocked_host · redirect_no_location · redirect_invalid_url · redirect_blocked:<ssrf reason> · too_many_redirects · fetch_failed:<status> · not_html · timeout · fetch_error. Until 2026-09-15 every network cause collapsed into `fetch_error`, so a 301 apex->www and an origin refusing the fetch outright were indistinguishable — read `detail` beside this."),
4430
+ detail: z.string().optional().describe("The underlying cause in words, ≤200 chars — the exception class and message, the blocked hop, or the content-type that was refused. Present on most errors, never on success."),
4431
+ redirects: z.number().optional().describe("Hops followed before the answer. Redirects are followed MANUALLY, at most 4, and the SSRF guard re-runs on every hop — a public URL that redirects to a private address is refused at that hop with redirect_blocked."),
4432
+ }),
4021
4433
  effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "public",
4022
4434
  }),
4023
4435
  // ── people / companies / conversation — handlers in resolvers/groups.ts ────────
@@ -4319,7 +4731,7 @@ export const RECEIVERS = {
4319
4731
  receiver: "workflow:runs",
4320
4732
  surfaces: { mcp: true },
4321
4733
  summary: "List recent runs of a workflow (D1 workflow_run) for the monitor + history surfaces",
4322
- request: z.object({ workflowId: z.string(), runId: z.string().optional(), limit: z.number().int().min(1).max(200).optional() }),
4734
+ request: z.object({ workflowId: z.string(), runId: z.string().optional(), limit: z.number().int().min(1).max(200).optional(), stepId: z.string().optional(), stepLimit: z.number().int().min(1).max(100).optional() }),
4323
4735
  response: z.object({
4324
4736
  runs: z.array(z.object({
4325
4737
  id: z.string(), status: z.string(),
@@ -5510,6 +5922,325 @@ export const RECEIVERS = {
5510
5922
  reversible: false, idempotent: false, settles: "none",
5511
5923
  examples: [{ slug: "story:demo", kept: false }],
5512
5924
  }),
5925
+ // ── duty: — provision that was PROMISED, recorded as delivered or missed ──
5926
+ // (resolvers/duty.ts · migrations/0245_duties.sql · lib/ehc/duty.ts)
5927
+ //
5928
+ // The family exists because `promise:*` inverts wrong for a statutory duty:
5929
+ // `promises.ts:96` lets the maker settle its own promise, and `:112` defaults
5930
+ // a missing verdict to KEPT. Here the certifier is a DIFFERENT actor, refused
5931
+ // at write time by `CHECK (certifier <> holder)`, and NO caller supplies a
5932
+ // verdict at all — the state is computed from counts and the clock, so a
5933
+ // window that lapsed with nothing recorded reads as `breached`, not as blank.
5934
+ // Spec: text/ehc-substrate-plan.md §3.1.
5935
+ "duty:make": receiver({
5936
+ receiver: "duty:make",
5937
+ summary: "State a duty you OWE — terms, one observable, an owed count, a due_at, and a certifier who must not be you. The holder is the attested caller, never a body field; certifier === holder is refused by the resolver and by a D1 CHECK",
5938
+ request: z.object({
5939
+ slug: z.string().describe("Stable id, e.g. ehc:cyp-005:H2. [A-Za-z0-9_:/-], max 128"),
5940
+ terms: z.string().describe("What is owed — hashed verbatim into terms_hash"),
5941
+ observable: z.string().describe("What ONE delivery looks like: 'a meal, on a date, received' — not 'a meal service was commissioned'"),
5942
+ certifier: z.string().describe("Who may attest delivery. MUST NOT be the holder — the duty-holder never grades its own homework"),
5943
+ owed: z.number().int().describe("How many deliveries are owed. 0 means not yet quantified and computes to state 'unknown', never 'met'"),
5944
+ due_at: z.number().optional().describe("Epoch ms the window closes. ABSENT IS UNKNOWN, never 'no deadline' — an unwritten window computes to 'unknown', never to on-track"),
5945
+ }),
5946
+ response: z.object({
5947
+ ok: z.boolean(),
5948
+ slug: z.string().optional(),
5949
+ version: z.number().optional(),
5950
+ state: z.enum(["unknown", "owed", "part-met", "met", "breached"]).optional(),
5951
+ terms_hash: z.string().optional(),
5952
+ certifier: z.string().optional(),
5953
+ error: z.string().optional(),
5954
+ }),
5955
+ effect: "ask", cost: "free", auth: "member",
5956
+ reversible: false, idempotent: true, settles: "none",
5957
+ examples: [{ slug: "ehc:cyp-005:H2", terms: "A hot meal every weekday, including school holidays", observable: "A meal, on a date, received", certifier: "ehc-social-worker", owed: 45 }],
5958
+ }),
5959
+ "duty:amend": receiver({
5960
+ receiver: "duty:amend",
5961
+ summary: "Amend by LINEAGE — writes a new row at version+1 with `supersedes`; the prior version is never mutated and never deleted. Holder only today: an amendment against the holder's will needs a tribunal rung that does not exist yet (§3.3 Q3) and is refused by name",
5962
+ request: z.object({
5963
+ slug: z.string(),
5964
+ terms: z.string().optional(),
5965
+ observable: z.string().optional(),
5966
+ certifier: z.string().optional().describe("Still must not equal the holder"),
5967
+ owed: z.number().int().optional(),
5968
+ due_at: z.number().nullable().optional(),
5969
+ }),
5970
+ response: z.object({
5971
+ ok: z.boolean(),
5972
+ slug: z.string().optional(),
5973
+ version: z.number().optional(),
5974
+ supersedes: z.string().nullable().optional(),
5975
+ state: z.enum(["unknown", "owed", "part-met", "met", "breached"]).optional(),
5976
+ terms_hash: z.string().optional(),
5977
+ blocked_on: z.string().optional().describe("'tribunal-rung' — the missing authority, named rather than silently allowed"),
5978
+ error: z.string().optional(),
5979
+ }),
5980
+ effect: "ask", cost: "free", auth: "member",
5981
+ reversible: false, idempotent: false, settles: "none",
5982
+ examples: [{ slug: "ehc:cyp-005:H2", owed: 190, terms: "A hot meal every weekday of the YEAR, holidays included" }],
5983
+ }),
5984
+ "duty:evidence": receiver({
5985
+ receiver: "duty:evidence",
5986
+ summary: "File ONE delivery against the current version — the CERTIFIER only, matched against the persisted certifier from the attested caller. Idempotent by sha256(slug‖version‖ref), so a re-file changes nothing. A new delivery MARKS the holder's path",
5987
+ request: z.object({
5988
+ slug: z.string(),
5989
+ ref: z.string().describe("The delivery, named: 'meal 2026-09-12', 'SLT session note wk37'"),
5990
+ version: z.number().int().optional().describe("Defaults to the current version; an older one is refused, not back-filed quietly"),
5991
+ }),
5992
+ response: z.object({
5993
+ ok: z.boolean(),
5994
+ slug: z.string().optional(),
5995
+ version: z.number().optional(),
5996
+ id: z.string().optional(),
5997
+ evidenced: z.number().optional().describe("A COUNT over duty_evidence, never a stored column"),
5998
+ owed: z.number().optional(),
5999
+ state: z.enum(["unknown", "owed", "part-met", "met", "breached"]).optional(),
6000
+ recorded: z.boolean().optional().describe("false ⇒ this exact delivery was already on record"),
6001
+ error: z.string().optional(),
6002
+ }),
6003
+ effect: "ask", cost: "free", auth: "member",
6004
+ reversible: false, idempotent: true, settles: "none",
6005
+ examples: [{ slug: "ehc:cyp-005:H2", ref: "hot meal received 2026-09-12" }],
6006
+ }),
6007
+ "duty:breach": receiver({
6008
+ receiver: "duty:breach",
6009
+ summary: "Compute the verdict and land it in the record. SILENCE IS BREACH: owed > count(evidence) with the window closed is `breached`, and no caller supplies a verdict. Warns the holder's path on the transition into breach — once, because the TRANSITION is the event. `breached` is NOT terminal: late evidence recomputes to `met`",
6010
+ request: z.object({ slug: z.string() }),
6011
+ response: z.object({
6012
+ ok: z.boolean(),
6013
+ slug: z.string().optional(),
6014
+ version: z.number().optional(),
6015
+ state: z.enum(["unknown", "owed", "part-met", "met", "breached"]).optional(),
6016
+ changed: z.boolean().optional(),
6017
+ owed: z.number().optional(),
6018
+ evidenced: z.number().optional(),
6019
+ due_at: z.number().nullable().optional(),
6020
+ holder: z.string().optional(),
6021
+ breached: z.boolean().optional(),
6022
+ error: z.string().optional(),
6023
+ }),
6024
+ effect: "ask", cost: "free", auth: "member",
6025
+ reversible: true, idempotent: true, settles: "none",
6026
+ examples: [{ slug: "ehc:cyp-005:H2" }],
6027
+ }),
6028
+ "duty:get": receiver({
6029
+ receiver: "duty:get",
6030
+ summary: "Read a duty with its state COMPUTED, writing nothing — the read a plan surface makes. NOT public (a duty names a child): holder, certifier or platform staff only. The family's own rung is not modelled yet",
6031
+ request: z.object({ slug: z.string() }),
6032
+ response: z.object({
6033
+ ok: z.boolean(),
6034
+ slug: z.string().optional(),
6035
+ version: z.number().optional(),
6036
+ supersedes: z.string().nullable().optional(),
6037
+ holder: z.string().optional(),
6038
+ certifier: z.string().optional(),
6039
+ terms: z.string().optional(),
6040
+ observable: z.string().optional(),
6041
+ owed: z.number().optional(),
6042
+ evidenced: z.number().optional(),
6043
+ due_at: z.number().nullable().optional(),
6044
+ state: z.enum(["unknown", "owed", "part-met", "met", "breached"]).optional(),
6045
+ stored_state: z.string().optional().describe("The cached column. It should equal `state`; a divergence means nothing has recomputed since the window closed"),
6046
+ error: z.string().optional(),
6047
+ }),
6048
+ effect: "ask", cost: "free", auth: "member",
6049
+ reversible: true, idempotent: true, settles: "none",
6050
+ examples: [{ slug: "ehc:cyp-005:H2" }],
6051
+ }),
6052
+ // ── ui: — an agent's hand on the operator's own browser ───────────────────
6053
+ //
6054
+ // The ONLY receiver in the registry whose effect is a MOVE OF A HUMAN'S
6055
+ // SCREEN, so it is the only one where "authority off ctx, never the payload"
6056
+ // is not hygiene but the whole feature. Given a target-viewer field, this
6057
+ // receiver is a machine for pushing any actor's reader anywhere — so there is
6058
+ // no such field, and a payload that names one is REFUSED rather than ignored
6059
+ // (silently stripping it would answer ok:true to a caller whose model of the
6060
+ // world is wrong, and teach it the call worked).
6061
+ "ui:navigate": receiver({
6062
+ receiver: "ui:navigate",
6063
+ summary: "Move the CALLER'S OWN reader to a same-origin path. Two independent checks, because one of them runs in code an untrusted caller can reach: (1) the SERVER addresses only the session that made this call — authority comes off the attested ctx, there is NO field naming a principal — `viewer`, `viewerId`, `actorId`, `actor`, `target`, `targetViewer`, `targetActor`, `session`, `sessionId`, `userId`, `uid`, `ownerSlug`, `audience` — and a payload carrying any of them is REFUSED outright rather than silently stripped (`workspace`/`slug` are NOT refused: they scope, they cannot name a reader); (2) the BROWSER independently drops any command that did not arrive on a stream it opened itself, so a command that somehow escaped (1) still moves nobody. Same-origin PATHS only — an absolute URL, a scheme, or a protocol-relative href is refused, never rewritten.",
6064
+ request: z
6065
+ .object({
6066
+ href: z
6067
+ .string()
6068
+ .min(1)
6069
+ .describe("Where to. A SAME-ORIGIN PATH: one leading '/', e.g. '/u/one/in'. Refused, never sanitised: an absolute URL ('https://evil.com/x'), any scheme ('javascript:', 'data:'), a protocol-relative href ('//evil.com'), the backslash form ('/\\evil.com'), a bare host ('evil.com/x'), and any href containing a tab/CR/LF. Measured against node's WHATWG parser: new URL('//evil.com', 'https://one.ie'), new URL('/\\evil.com', …) and new URL('/<TAB>/evil.com', …) ALL resolve to https://evil.com/ — the parser strips tab/CR/LF anywhere in the input, so the tab form is a real bypass of a naive startsWith('/') check. A navigate receiver that accepts arbitrary hrefs is an open redirect with an agent attached."),
6070
+ reason: z
6071
+ .enum(["select", "command", "agent"])
6072
+ .optional()
6073
+ .describe("Why the move happened, for the host's own judgment. Mirrors NavigateRequest.reason in one.ie/web/src/lib/navigate.ts, and is ECHOED back on the response so the transport has something to forward. CALLER-ASSERTED and carrying NO authority: an agent is free to send 'select', which reads as 'an operator clicked'. Nothing gates on it, and a host must not read it as evidence that a human was there."),
6074
+ replace: z
6075
+ .boolean()
6076
+ .optional()
6077
+ .describe("Replace the current history entry instead of pushing a new one. Default false: collapsing the entry deletes the operator's way back, so it is opt-in."),
6078
+ })
6079
+ .describe("There is deliberately no field naming WHO to move. The reader moved is always the caller's own, resolved from the attested session server-side. If you find yourself wanting a target, that is the defect arriving."),
6080
+ response: z.object({
6081
+ ok: z.boolean(),
6082
+ href: z.string().optional().describe("The href as accepted — byte-identical to the request, never a rewritten one"),
6083
+ replace: z.boolean().optional(),
6084
+ reason: z
6085
+ .enum(["select", "command", "agent"])
6086
+ .optional()
6087
+ .describe("The request's `reason`, echoed unchanged. Caller-asserted — never evidence of a human"),
6088
+ audience: z
6089
+ .string()
6090
+ .optional()
6091
+ .describe("The caller's OWN attested slug — echoed so a caller can see whose reader it addressed. Never a value the caller supplied"),
6092
+ delivered: z
6093
+ .boolean()
6094
+ .optional()
6095
+ .describe("Whether the command actually reached a browser. FALSE today for every call: the receiver validates and addresses, and the transport that carries it to the reader is a separate row (task:01a0a3789956c0b2d78d2487). A receiver that reached nobody must say so rather than answer a bare ok:true — the route:to-* namespace is the standing example of what the other choice costs"),
6096
+ note: z.string().optional(),
6097
+ error: z.string().optional(),
6098
+ }),
6099
+ effect: "ask",
6100
+ cost: "free",
6101
+ auth: "session",
6102
+ reversible: true,
6103
+ idempotent: false,
6104
+ settles: "none",
6105
+ examples: [{ href: "/u/one/in", reason: "agent" }],
6106
+ }),
6107
+ // ── media:* ─────────────────────────────────────────────────────────────────
6108
+ // THE WORKSPACE MEDIA LIBRARY, for an agent.
6109
+ //
6110
+ // WHY. Measured 2026-09-16 over this file: 362 receivers, and not one touched a
6111
+ // stored asset. `video:*` (14) is rooms, sessions and recordings — a call, never a
6112
+ // file. So the media console at `/u/<slug>/media` was HTTP-only: a human could list,
6113
+ // upload and generate; an agent could not reach any of it, from any surface.
6114
+ //
6115
+ // THE ONE AUTHORITY RULE, and it is not either HTTP door's. `workspace` is a SCOPE
6116
+ // REQUEST on all three — never an identity, never a default. It is honoured only
6117
+ // through the owner-tree walk, and a request the caller cannot clear is REFUSED BY
6118
+ // NAME. The three HTTP write doors (`api/media/generate.ts:35`, storefront
6119
+ // image-upload/image-generate) instead derive from `locals.slug` and IGNORE the
6120
+ // workspace handed to them, so an agency uploading at `/u/client/media` files into
6121
+ // the AGENCY's library while the grid shows the CLIENT's — and answers 200. These
6122
+ // receivers do not inherit that: a silent downgrade is a success you cannot audit.
6123
+ // Resolver: `one.ie/web/src/lib/resolvers/media.ts`.
6124
+ "media:list": receiver({
6125
+ receiver: "media:list",
6126
+ surfaces: { mcp: true, chat: true },
6127
+ summary: "Every image and video a workspace owns, as typed rows — ONE R2 scan of `{workspace}/` filtered to the seven prefixes the reader will actually serve (media, chat, ads, products, pages, brand, courses), so generated chat images and ad creative are visible, not just hand-uploads. Carries `scanned` (how much of the page was not media), `truncated` (the budget that bit) and `nextCursor`. Each row's `url` is the one door that reads bytes.",
6128
+ request: z.object({
6129
+ workspace: z.string().optional().describe("Group slug. Omit for your own. Honoured only if you may read it."),
6130
+ kind: z.enum(["image", "video"]).optional().describe("Omit for both. Filters the rows on this page; it does not change what is scanned."),
6131
+ prefix: z.enum(["media", "chat", "ads", "products", "pages", "brand", "courses"]).optional()
6132
+ .describe("Only assets under this top-level namespace. media = hand-uploads and library generations; chat = images/videos an agent made in a conversation; ads = creative; products/pages/courses/brand = assets filed beside the thing they illustrate."),
6133
+ limit: z.number().int().min(1).max(1000).optional()
6134
+ .describe("R2 objects to SCAN on this page BEFORE prefix/kind filtering — not a row count. Default 500, max 1000 (R2's own ceiling). A workspace whose bucket is mostly non-media can return few rows from a full scan; that is what `scanned` reports."),
6135
+ cursor: z.string().optional().describe("The nextCursor from the previous page."),
6136
+ }),
6137
+ response: z.object({
6138
+ ok: z.boolean(),
6139
+ workspace: z.string().optional().describe("The workspace actually read — always the one you asked for, because a request you may not read is refused rather than downgraded."),
6140
+ items: z.array(z.object({
6141
+ key: z.string().describe("The R2 object key, `{workspace}/{prefix}/…` — the handle every other media receiver takes."),
6142
+ url: z.string().describe("`/api/product-image/{workspace}/{path}` — THE reader. Range-forwarding, prefix-allowlisted, refuses SVG by stored contentType."),
6143
+ kind: z.enum(["image", "video"]),
6144
+ prefix: z.string().describe("Top-level namespace, no trailing slash."),
6145
+ contentType: z.string(),
6146
+ size: z.number().nullable().describe("Bytes, or null when R2 did not report it."),
6147
+ uploaded: z.string().nullable().describe("ISO time, or null."),
6148
+ })).optional(),
6149
+ total: z.number().optional()
6150
+ .describe("Media rows in THIS answer, after the kind/prefix filter. NOT a pre-paging count — an R2 scan cannot know one without walking every page. A FLOOR whenever `truncated` is present: say 'at least N'."),
6151
+ scanned: z.number().optional()
6152
+ .describe("Objects R2 listed on this page BEFORE filtering. `scanned` far above `total` means most of the page was not media — follow the cursor rather than concluding the workspace is empty."),
6153
+ byPrefix: z.record(z.string(), z.number()).optional().describe("What each namespace contributed to THIS page — never a workspace total."),
6154
+ prefixes: z.array(z.string()).optional().describe("Every prefix this door will ever return, so a caller can offer the filter without hardcoding the list."),
6155
+ truncated: z.object({
6156
+ rows: z.number().optional(),
6157
+ scanned: z.number().optional(),
6158
+ limit: z.number().optional().describe("The scan budget that bit."),
6159
+ reason: z.string().optional(),
6160
+ }).optional().describe("ABSENT IS NOT FALSE — a complete answer carries no such key. Present = R2 reported more objects under this workspace, `total` is a floor, and you must say so before planning on it."),
6161
+ nextCursor: z.string().optional().describe("Present iff `truncated` is. Follow it or you have not seen the bucket."),
6162
+ error: z.string().optional(),
6163
+ }),
6164
+ effect: "ask", cost: "free", idempotent: true, reversible: true, settles: "none", auth: "member",
6165
+ examples: [
6166
+ { workspace: "one" },
6167
+ { workspace: "one", kind: "video" },
6168
+ { prefix: "ads", limit: 1000 },
6169
+ ],
6170
+ }),
6171
+ // media:upload — bytes in, one key out, under `{workspace}/media/`.
6172
+ //
6173
+ // TWO SHAPES BECAUSE AN AGENT HAS NEITHER A FILE PICKER NOR A MULTIPART BODY. The
6174
+ // HTTP door takes multipart and nothing else, so this is not a wrapper: the R2 write
6175
+ // and the magic-byte sniff are reimplemented at the same 5 MiB cap.
6176
+ //
6177
+ // `url` IS A NEW OUTBOUND-FETCH SURFACE and is treated as one — the shared SSRF guard
6178
+ // (shape + DNS, fail-closed) runs BEFORE the fetch, `redirect:'error'` so a public
6179
+ // host cannot 302 the worker onto a metadata address, and the body is read against a
6180
+ // hard byte budget rather than trusting `content-length`.
6181
+ "media:upload": receiver({
6182
+ receiver: "media:upload",
6183
+ surfaces: { mcp: true, chat: true },
6184
+ summary: "Put ONE raster image into a workspace's media library and get back the key and the url that serves it. Name exactly one source: `base64` (bytes you hold) or `url` (a public https address the server fetches under an SSRF guard, no redirects, 5 MiB hard cap). The stored content type comes from the MAGIC BYTES, never from what you declare — SVG is refused deliberately, and video is not accepted here.",
6185
+ request: z.object({
6186
+ workspace: z.string().optional().describe("Group slug. Omit for your own. Honoured only if you may read it."),
6187
+ url: z.string().optional().describe("Public https address to fetch. Alternative to `base64`, never both. Private, loopback, link-local and CGNAT addresses are refused, as is any redirect."),
6188
+ base64: z.string().optional().describe("The image bytes, base64. A `data:` prefix is accepted and stripped. Alternative to `url`, never both."),
6189
+ filename: z.string().optional().describe("A LABEL for the key, not a path — separators are stripped and the extension always comes from the sniffed bytes, so `../` and a `.svg` suffix are both inert. Omit and the key is a bare uuid."),
6190
+ contentType: z.string().optional().describe("A HINT only. The stored value is decided by the magic bytes, because the public reader serves the stored value verbatim under nosniff."),
6191
+ }),
6192
+ response: z.object({
6193
+ ok: z.boolean(),
6194
+ workspace: z.string().optional(),
6195
+ key: z.string().optional().describe("`{workspace}/media/…` — the handle media:list returns."),
6196
+ url: z.string().optional().describe("`/api/product-image/{workspace}/{path}` — THE reader."),
6197
+ kind: z.literal("image").optional().describe("Always image. Video is a 5 MiB-cap and multipart-shape decision this door does not make; PUT /api/media/video-upload is the door that does."),
6198
+ contentType: z.string().optional().describe("Sniffed, not declared."),
6199
+ size: z.number().optional().describe("Bytes actually stored."),
6200
+ error: z.string().optional().describe("Named: forbidden · too_large · unsupported_type · blocked_url · fetch_failed · storage_error. A refusal is never a downgrade to your own workspace."),
6201
+ }),
6202
+ effect: "ask", cost: "free", idempotent: false, reversible: false, settles: "none", auth: "member",
6203
+ examples: [
6204
+ { workspace: "one", url: "https://example.com/logo.png", filename: "logo" },
6205
+ { base64: "iVBORw0KGgo…" },
6206
+ ],
6207
+ }),
6208
+ // media:generate — one image from a prompt, straight into `{workspace}/media/`.
6209
+ //
6210
+ // DELIBERATELY NOT `surfaces.chat`. Chat already ships a curated `generate_image`
6211
+ // (`channels/src/aitools.ts:376`): three providers, aspect/width/height, per-call
6212
+ // billing and a spend-shaped approval gate — strictly more capable there, and it
6213
+ // files under `{ws}/chat/`. Flagging this for chat too would offer a model TWO image
6214
+ // generators with different prefixes and no way to choose between them, and the names
6215
+ // differ (`generate_image` vs `media_generate`) so the curated-wins precedence in
6216
+ // `channels/src/tools/from-registry.ts` would not suppress it. MCP has no such tool;
6217
+ // that is where this one earns its place.
6218
+ //
6219
+ // `context` is a PROMPT QUALIFIER, not a key prefix. A library is THE prefix, not a
6220
+ // context — the distinction `api/media/generate.ts`'s header argues out against the
6221
+ // contextual storefront door. Nothing this receiver writes lands outside `media/`.
6222
+ "media:generate": receiver({
6223
+ receiver: "media:generate",
6224
+ surfaces: { mcp: true },
6225
+ summary: "Generate ONE image from a prompt and file it in the workspace's media library. Keyless — flux-1-schnell on the Cloudflare AI binding. Returns the key and the url that serves it. Unlike the storefront generator this takes no destination: it always writes `{workspace}/media/`, because a library is a prefix and not a context.",
6226
+ request: z.object({
6227
+ workspace: z.string().optional().describe("Group slug. Omit for your own. Honoured only if you may read it."),
6228
+ prompt: z.string().min(1).describe("What to draw. Truncated at 2000 chars after `context` is folded in."),
6229
+ context: z.string().optional().describe("Extra prompt material — brand, palette, subject. A QUALIFIER appended to the prompt, NOT a destination: it never changes the key prefix."),
6230
+ }),
6231
+ response: z.object({
6232
+ ok: z.boolean(),
6233
+ workspace: z.string().optional(),
6234
+ key: z.string().optional().describe("`{workspace}/media/{uuid}.{ext}` — a uuid, never a timestamp: the url it names is served by a PUBLIC reader, so the name is the only thing standing between the object and a stranger."),
6235
+ url: z.string().optional().describe("`/api/product-image/{workspace}/{path}` — THE reader."),
6236
+ kind: z.literal("image").optional(),
6237
+ contentType: z.string().optional().describe("Sniffed from the returned bytes — flux returns JPEG today and returned PNG when the storefront door was written."),
6238
+ size: z.number().optional(),
6239
+ error: z.string().optional().describe("Named: forbidden · not_configured (no AI or CONTENT binding) · generation_failed · storage_error."),
6240
+ }),
6241
+ effect: "ask", cost: "variable", idempotent: false, reversible: false, settles: "none", auth: "member",
6242
+ examples: [{ workspace: "one", prompt: "a wide banner of a quiet harbour at dawn, muted palette" }],
6243
+ }),
5513
6244
  };
5514
6245
  /**
5515
6246
  * RECIPES — the four agent journeys as typed, ordered receiver sequences (C6).