@oneie/sdk 0.14.14 → 0.15.1

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",
@@ -913,6 +974,16 @@ export const RECEIVERS = {
913
974
  board: z.literal("marketplace").optional(),
914
975
  slug: z.string().optional(),
915
976
  workspace: z.string().optional(),
977
+ // Rides through unchanged to buildMessage (string field, unaffected)
978
+ // and is what lets an inbox card show a title instead of a bare tid.
979
+ title: z.string().optional(),
980
+ // The structured envelope (`web/src/lib/signal-meta.ts SignalMeta`) —
981
+ // an object, so buildMessage's string/number-only flatten drops it from
982
+ // the text body by construction. Declared loosely here (not the exact
983
+ // shape) because this is the ONE cross-cutting schema every announce
984
+ // caller reuses; a stricter zod object would have to be kept in lockstep
985
+ // with the TS type by hand.
986
+ meta: z.record(z.string(), z.unknown()).optional(),
916
987
  }),
917
988
  response: z.object({ ok: z.boolean(), taskId: z.string().optional(), matched: z.number().optional(), actors: z.array(z.string()).optional() }),
918
989
  effect: "ask",
@@ -967,6 +1038,11 @@ export const RECEIVERS = {
967
1038
  name: z.string(),
968
1039
  tags: z.array(z.string()),
969
1040
  weight: z.number(),
1041
+ // Shipped by the resolver (subscriptions.ts § tasks:everywhere) and undeclared
1042
+ // until now. The response check is WARN-only (bind-receiver.ts:247), so the
1043
+ // field arrived while the contract denied it — and every reader generated FROM
1044
+ // the contract (metaSchema, the MCP tool schema, OpenAPI) could not see it.
1045
+ priority: z.number().optional().describe("Task priority; the ranking input behind `weight`"),
970
1046
  notes: z.string().optional(),
971
1047
  contextDocs: z.array(z.string()).optional(),
972
1048
  workspace: z.string(),
@@ -978,6 +1054,10 @@ export const RECEIVERS = {
978
1054
  offeredByMe: z.boolean(),
979
1055
  closedAt: z.string().optional(),
980
1056
  })),
1057
+ // A cap that reports itself (the /api/things standard). The old 200 clamp
1058
+ // was silent: a 595-row queue came back as 200 rows and read as complete.
1059
+ total: z.number().optional().describe("Visible rows before the limit was applied"),
1060
+ truncated: z.boolean().optional().describe("true = more rows exist than were returned; raise limit or use tasks:board"),
981
1061
  }),
982
1062
  effect: "ask", idempotent: true,
983
1063
  }),
@@ -1045,6 +1125,39 @@ export const RECEIVERS = {
1045
1125
  }),
1046
1126
  effect: "ask", idempotent: true, reversible: true,
1047
1127
  }),
1128
+ "factory:recon": receiver({
1129
+ receiver: "factory:recon",
1130
+ 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",
1131
+ request: z.object({
1132
+ // The captured sentence. do-triage reads what the text LITERALLY names —
1133
+ // there is no model here, so a sentence that names nothing answers `idea`.
1134
+ intent: z.string().max(2000),
1135
+ // Paths a caller already knows. Accepted so a workflow step can pass the
1136
+ // trigger through whole; recon derives its own from `intent` and does not
1137
+ // widen the answer with these.
1138
+ paths: z.array(z.string()).max(2000).optional(),
1139
+ // The trigger's tags, carried not read. Declared for one measured reason:
1140
+ // zod `.object()` STRIPS unknown keys, so an undeclared field arriving from
1141
+ // a workflow step is a shape error rather than a no-op. It decides nothing —
1142
+ // same rule as factory:size's `workspace`: a body field that decided nothing
1143
+ // cannot be spoofed into deciding something.
1144
+ tags: z.array(z.string()).max(64).optional(),
1145
+ }),
1146
+ response: z.object({
1147
+ // Always true. There is no `ok:false` branch in this receiver.
1148
+ ok: z.boolean(),
1149
+ verdict: z.enum(["sizeable", "idea", "dupe", "split"]).optional(),
1150
+ // Repo-relative, existing, in the order the sentence names them. This is the
1151
+ // array that goes to factory:size unchanged.
1152
+ paths: z.array(z.string()).optional(),
1153
+ route: z.string().nullable().optional(),
1154
+ // Every path-SHAPED token the sentence named, before the existence filter.
1155
+ // The edge can confirm fewer paths than the shell can (it has a page glob,
1156
+ // not a repo), and this is that difference made legible rather than hidden.
1157
+ named: z.array(z.string()).optional(),
1158
+ }),
1159
+ effect: "ask", idempotent: true, reversible: true,
1160
+ }),
1048
1161
  "factory:attempt": receiver({
1049
1162
  receiver: "factory:attempt",
1050
1163
  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",
@@ -1084,6 +1197,73 @@ export const RECEIVERS = {
1084
1197
  }),
1085
1198
  effect: "ask", idempotent: true,
1086
1199
  }),
1200
+ "deploy:event": receiver({
1201
+ receiver: "deploy:event",
1202
+ 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",
1203
+ request: z.object({
1204
+ // The deploy's own run id — deploy.sh already mints one for its log name.
1205
+ // It IS half the run key, which is why a retried gate appends to its own
1206
+ // run instead of minting a second one. The sha cannot serve: the same sha
1207
+ // ships to the same target repeatedly (2026-09-06: af3ba5919 gates-only,
1208
+ // red, green, then shipped), and keying on it would interleave them.
1209
+ run: z.string(),
1210
+ // Where it ships. The other half of the key, so "did pay go out" is
1211
+ // answerable without disambiguating five services by timestamp.
1212
+ target: z.string(),
1213
+ // Must equal DEPLOY_SPINE_STEPS (one.ie/web/src/lib/deploy/event.ts).
1214
+ // Retyped because packages/sdk cannot import from one.ie/web; pinned by
1215
+ // tests/unit/deploy/event.test.ts so the two cannot drift.
1216
+ stage: z.enum([
1217
+ "tree", "credentials", "typecheck", "tests", "build",
1218
+ "smoke", "approval", "migrations", "ship", "health",
1219
+ ]).optional(),
1220
+ // There is deliberately no "skip": a gate that did not run must be ABSENT.
1221
+ // deploy.sh already reports four gate states (pass|fail|unrun|n/a) and an
1222
+ // unrun gate is never a pass — a "skip" frame would render like one.
1223
+ status: z.enum(["start", "ok", "fail"]).optional(),
1224
+ // Present only on the final call, and it is what CLOSES the run. Separate
1225
+ // from the stages because --gates-only is a complete, successful run that
1226
+ // legitimately never reaches `ship` or `health`; closing on the last stage
1227
+ // would leave every one of those runs "open" forever.
1228
+ verdict: z.enum(["green", "red"]).optional(),
1229
+ sha: z.string().optional(),
1230
+ // Which door ran it: ./deploy, release.sh ship, land --pr --deploy.
1231
+ door: z.string().optional(),
1232
+ // Board workspace. TAGS ONLY inside the resolver — effectiveWorkspace
1233
+ // reads `workspace` and never `slug`. The same caller-nomination note as
1234
+ // factory:event's `slug` field applies verbatim for a service caller.
1235
+ slug: z.string().optional(),
1236
+ // "dev" flags the projected run is_test so dev.one.ie never pollutes the
1237
+ // production list. --dry-run does not reach here at all: the emitter is
1238
+ // not called on that path, because a run row for a deploy that did not
1239
+ // happen is the theater /deploy already refuses to render.
1240
+ env: z.string().optional(),
1241
+ reason: z.string().optional(),
1242
+ // This gate's own wall clock. Written to workflow_run_event.latency_ms —
1243
+ // the column factory:event binds NULL because a factory stage has no
1244
+ // measured duration. It is the number this whole receiver exists for.
1245
+ wallMs: z.number().optional(),
1246
+ // reused / waived / services — see DeployEventPayload.detail.
1247
+ detail: z.record(z.string(), z.unknown()).optional(),
1248
+ workspace: z.string().optional(),
1249
+ }),
1250
+ response: z.object({
1251
+ ok: z.boolean(),
1252
+ runId: z.string().optional(),
1253
+ stage: z.string().optional(),
1254
+ status: z.string().optional(),
1255
+ verdict: z.string().optional(),
1256
+ events: z.number().optional(),
1257
+ slug: z.string().optional(),
1258
+ error: z.string().optional(),
1259
+ }),
1260
+ // Same floor and same reasoning as factory:event: authClassFor(undefined)
1261
+ // returns 'open', and the 'authenticated' floor tests exactly the predicate
1262
+ // the handler's own guard uses, one layer earlier. Declaring it makes the
1263
+ // fail-closed intent survive a refactor of the handler.
1264
+ auth: "required",
1265
+ effect: "ask", idempotent: true,
1266
+ }),
1087
1267
  "factory:event": receiver({
1088
1268
  receiver: "factory:event",
1089
1269
  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",
@@ -1160,6 +1340,171 @@ export const RECEIVERS = {
1160
1340
  // a dedupe. The RUN row dedupes (INSERT OR IGNORE); the events do not.
1161
1341
  effect: "ask", idempotent: false,
1162
1342
  }),
1343
+ // ── ehc: ───────────────────────────────────────────────────────────────────
1344
+ //
1345
+ // The first member of this namespace, and it sets the convention: an `ehc:`
1346
+ // receiver acts on ONE named child's plan, and the child's address comes from
1347
+ // the case registry (`one.ie/web/src/lib/ehc/address.ts`), never from a string
1348
+ // a caller typed.
1349
+ //
1350
+ // Read the summary as a limit, not a feature list. This door cannot record a
1351
+ // CYP's own words \u2014 it accepts no voice key, so every record it writes is a
1352
+ // proxy record and renders as MARKED INTERPRETATION naming the adult. A child
1353
+ // speaks through the page that minted their credential. An agent-class caller
1354
+ // is refused outright: Section A is what a child said or an adult's marked
1355
+ // account of what a child communicated, and a generated sentence is neither.
1356
+ "ehc:voice-record": receiver({
1357
+ receiver: "ehc:voice-record",
1358
+ 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)",
1359
+ request: z.object({
1360
+ cyp: z.string(), // case id, e.g. 'cyp-005' \u2014 resolved through the registry
1361
+ body: z.string(), // exactly what was communicated. Never smoothed.
1362
+ proxyRole: z.string().optional(), // 'parent' | 'keyworker' | 'advocate' \u2014 shown to every reader
1363
+ prompt: z.string().optional(), // which portal question this answers
1364
+ }),
1365
+ response: z.object({
1366
+ ok: z.boolean(),
1367
+ id: z.string().optional(),
1368
+ provenance: z.string().optional(), // always 'proxy-observed' from this door
1369
+ saidAt: z.string().optional(),
1370
+ error: z.string().optional(), // 'not_found' | 'residency_undecided' | 'machines_do_not_speak_for_children' | 'no_voice' | 'empty' | 'too_long'
1371
+ detail: z.string().optional(),
1372
+ }),
1373
+ effect: "ask", cost: "free", reversible: false, idempotent: false, auth: "session",
1374
+ }),
1375
+ // The event a provision's `verification.closesOn` names (cases/cyp-001.ts H1).
1376
+ // A receiver cannot be per-child, so the child rides in `cyp` and the instance
1377
+ // name `ehc:<cyp>:meal-served` is what the event row carries. It records a
1378
+ // human tick that a meal was SERVED — never that one was ordered.
1379
+ "ehc:meal-served": receiver({
1380
+ receiver: "ehc:meal-served",
1381
+ 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",
1382
+ request: z.object({
1383
+ cyp: z.string(), // case id, e.g. 'cyp-001' — resolved through the registry
1384
+ meal: z.enum(["breakfast", "lunch", "tea"]),
1385
+ servedOn: z.string().optional(), // YYYY-MM-DD; defaults to today (UTC)
1386
+ }),
1387
+ response: z.object({
1388
+ ok: z.boolean(),
1389
+ id: z.string().optional(),
1390
+ event: z.string().optional(),
1391
+ provision: z.string().optional(),
1392
+ meal: z.string().optional(),
1393
+ servedOn: z.string().optional(),
1394
+ adult: z.string().optional(),
1395
+ error: z.string().optional(),
1396
+ detail: z.string().optional(),
1397
+ }),
1398
+ effect: "signal", cost: "free", reversible: false, idempotent: false, auth: "session",
1399
+ }),
1400
+ "story:chain": receiver({
1401
+ receiver: "story:chain",
1402
+ 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",
1403
+ request: z.object({
1404
+ // Where "a story is two or more" lives. The floor is the caller's, so 1
1405
+ // legitimately means "every origin"; the default is the contract's 2.
1406
+ minMembers: z.number().int().min(1).max(1_000_000).optional(),
1407
+ // Narrow to one story. The same floor still applies: an origin holding a
1408
+ // single signal answers count:0, because one signal is not yet a story.
1409
+ origin: z.string().max(128).optional(),
1410
+ // Page size only. Capped at 50 by the reader; it can never widen the scan.
1411
+ limit: z.number().int().min(1).max(50).optional(),
1412
+ }),
1413
+ response: z.object({
1414
+ ok: z.boolean(),
1415
+ count: z.number(),
1416
+ minMembers: z.number().optional(),
1417
+ stories: z.array(z.object({
1418
+ origin: z.string(),
1419
+ members: z.number(),
1420
+ // NULLABLE on purpose: `ts` is nullable in 0244, so MIN/MAX over a row
1421
+ // that carried none is NULL. Typing these bare numbers would be a lie
1422
+ // the response validator eventually catches in prod.
1423
+ first: z.number().nullable().optional(),
1424
+ last: z.number().nullable().optional(),
1425
+ })).optional(),
1426
+ }),
1427
+ // PUBLIC, and the reason is the ledger's shape, not an oversight. 0244 stores
1428
+ // signal_id, origin, receiver and ts \u2014 "no actor, no payload and no ip", in
1429
+ // its own words \u2014 so there is nothing here to scope to a tenant and nothing
1430
+ // to leak by reading it. The alternative is worse than permissive: the binder
1431
+ // attests on `staff || ownerSlug` (bind-receiver.ts applyAuth), and a service
1432
+ // caller nominates ownerSlug only via a `slug`/`workspace` body field, which
1433
+ // this contract has no reason to carry \u2014 so an `authenticated` label would
1434
+ // refuse the very call the promise's Proof makes.
1435
+ auth: "public",
1436
+ // A pure read. Same inputs, same answer, until a signal moves the number.
1437
+ effect: "ask", idempotent: true, reversible: true,
1438
+ }),
1439
+ // story:event — the funnel's doors. text/story-framework.md § "The story is
1440
+ // alive" names nine steps and schema/story.tql block 4 declares the EVENT
1441
+ // CONVENTION they are counted by; until this receiver existed nothing in the
1442
+ // estate WROTE one, so every block-4 fun answered 0 and block 5's story_death
1443
+ // answered "queen" for every story that had ever been told
1444
+ // (text/story-tracking-plan.md § Gaps 4). This is the write half.
1445
+ //
1446
+ // ONE signal per call, under the story's origin, carrying the block-4 payload
1447
+ // pairs: {"origin":"<o>","event":"story:<kind>"} plus "parent" on a retell and
1448
+ // an `amount` attribute on a convert. The graph write and the trail marks are
1449
+ // deferred to waitUntil — the brain takes the write, never the request path
1450
+ // (root CLAUDE.md § The brain and the edge).
1451
+ //
1452
+ // AUTH IS PER-KIND, which no single label can express, so the label is the
1453
+ // widest kind and the resolver holds the rest: `told`, `convert` and `retell`
1454
+ // refuse an unattested caller (they name a teller, move money, or claim
1455
+ // lineage), while `framed`/`view`/`complete`/`share` are open — a reader of a
1456
+ // published story has no session and a view nobody can send is not a funnel.
1457
+ // The identity is ALWAYS ctx, never the body (memory: resolver-body-actorid).
1458
+ "story:event": receiver({
1459
+ receiver: "story:event",
1460
+ 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",
1461
+ request: z.object({
1462
+ // The story. Minted by the door (C3) and echoed on every response, so a
1463
+ // caller always has one to quote; never derived from a task id.
1464
+ origin: z.string().min(1).max(128),
1465
+ // The nine-step funnel's kinds, minus the four a rung already writes
1466
+ // (promised/building/settled are the promise's own states).
1467
+ event: z.enum(["told", "framed", "view", "complete", "share", "convert", "retell"]),
1468
+ // Revenue on a convert. 0 is a real answer — a sign-up converts and moves
1469
+ // no money — so it is not conflated with absent.
1470
+ amount: z.number().nonnegative().max(1_000_000).optional(),
1471
+ // REQUIRED on a retell: the origin of the story this one was told from.
1472
+ // A retell without it is lineage that names no parent, which is what
1473
+ // story_children reads, so it is refused rather than written half-formed.
1474
+ parent: z.string().max(128).optional(),
1475
+ // What converted — the framework and the medium. Named on a convert, they
1476
+ // become the two marks; absent, the convert still counts and marks nothing.
1477
+ frame: z.string().max(64).optional(),
1478
+ medium: z.string().max(64).optional(),
1479
+ // TENANCY: honoured only when it equals the attested caller's own slug.
1480
+ // A caller that could name another board could poison another tenant's
1481
+ // routing weights (resolvers/subscriptions.ts:399-403).
1482
+ board: z.string().max(64).optional(),
1483
+ // Whether the medium played to the end — the one thing story_completes
1484
+ // reads `has success true` for. Defaults true.
1485
+ success: z.boolean().optional(),
1486
+ }),
1487
+ response: z.object({
1488
+ ok: z.boolean(),
1489
+ origin: z.string().optional(),
1490
+ // The full pair as written, e.g. "story:view" — so a caller can grep the
1491
+ // same string schema/story.tql matches on.
1492
+ event: z.string().optional(),
1493
+ // TRUE means the window already held this (origin, viewer, event) and no
1494
+ // second signal was written. A real answer, never an error.
1495
+ deduped: z.boolean().optional(),
1496
+ // The trails this call will move, as "<source>→<target>". Empty is honest:
1497
+ // a convert naming no frame and no medium marks nothing.
1498
+ marked: z.array(z.string()).optional(),
1499
+ error: z.string().optional(),
1500
+ }),
1501
+ // The widest kind. See the note above: the narrower kinds are gated in the
1502
+ // resolver on ctx, because one label cannot say "open to read, closed to tell".
1503
+ auth: "public",
1504
+ // It writes. Not idempotent: two views an hour apart are two views, and the
1505
+ // dedupe window is a window, not a key.
1506
+ effect: "signal", cost: "free", reversible: false, idempotent: false,
1507
+ }),
1163
1508
  "do:halt": receiver({
1164
1509
  receiver: "do:halt",
1165
1510
  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",
@@ -1203,6 +1548,11 @@ export const RECEIVERS = {
1203
1548
  // task. Without it the task is an orphan: on the board, unreachable from any plan.
1204
1549
  // Refused unless the caller has operate access to the parent.
1205
1550
  parent: z.string().optional(),
1551
+ // 0..1, the same fraction tasks:priority stores. Added 2026-09-14: without it this
1552
+ // door could not express a priority, so every row it filed was invisible to the only
1553
+ // sort the board pages by — 407 of 730 on the day it was added. Omit to leave the
1554
+ // attribute ABSENT (never judged), which is distinct from 0 (judged unimportant).
1555
+ priority: z.number().min(0).max(1).optional(),
1206
1556
  // The viewed /u/<slug> workspace to file the task under. Honored only when the
1207
1557
  // caller is authorized for it (attested staff or owner-tree control); otherwise
1208
1558
  // the resolver falls back to the caller's own slug. Reconciles the create tag
@@ -1504,6 +1854,26 @@ export const RECEIVERS = {
1504
1854
  slug: z.string().optional(),
1505
1855
  body: z.string().optional(),
1506
1856
  workspace: z.string().optional(),
1857
+ // WHICH CONVERSATION THIS CAME FROM. A comment and a chat message already
1858
+ // live in the same D1 message store (`getOrCreateThread`, keyed
1859
+ // `task:<tid>`); what was missing was a field naming the OTHER thread, so
1860
+ // a discussion in the inbox and the row it is about could not be joined.
1861
+ //
1862
+ // It must be DECLARED to exist: `validateReceiver` returns the parsed
1863
+ // object and a zod schema STRIPS undeclared keys, so an undeclared field
1864
+ // never reaches the resolver. That is the same mechanism behind this
1865
+ // door's older surprise — an unknown key degrades the call to a read that
1866
+ // still answers ok:true.
1867
+ threadId: z.string().optional(),
1868
+ // WHO WROTE IT. Declared because a zod schema STRIPS an undeclared key
1869
+ // before the resolver ever sees it — the exact mechanism that left
1870
+ // `thread:append`'s already-implemented agent branch unreachable for every
1871
+ // gateway caller. `authorKind` stays UNDECLARED for the same reason it
1872
+ // does there: its value is a TRUST LABEL, and a caller naming its own is
1873
+ // the estate's most-repeated defect. The resolver derives the label and
1874
+ // honours this field only for an ATTESTED platform-staff caller; a bare
1875
+ // agent key already speaks as itself from ctx and needs no field.
1876
+ author: z.string().optional(),
1507
1877
  }).refine((v) => !!v.tid || !!v.slug, {
1508
1878
  message: "address the task by tid or slug",
1509
1879
  path: ["tid"],
@@ -1562,6 +1932,226 @@ export const RECEIVERS = {
1562
1932
  }),
1563
1933
  effect: "ask", idempotent: true, auth: "member",
1564
1934
  }),
1935
+ // tasks:board — the ONE-REQUEST read of a whole board, with the context a planner needs.
1936
+ //
1937
+ // WHY. Measured 2026-09-13: an agent asked for the board, hit a 200-row page
1938
+ // (the MCP tool's documented cap; `tasks:everywhere` clamps to 200), paged by six
1939
+ // tags, found 353 more rows by NAME ONLY, and never learned the real total.
1940
+ // `tasks:list` has no cap but refuses without a `tag`, so the unfiltered
1941
+ // question — "every task in this group" — had no door at all.
1942
+ //
1943
+ // CONTRACT. (1) `total` is the count that matched the filters BEFORE paging, and
1944
+ // is never an estimate; a partial page carries `nextCursor`, and any budget the
1945
+ // underlying read could not honour is named in `truncated` — a short answer
1946
+ // always says it is short. (2) `summary` is computed over ALL matched rows, not
1947
+ // the page, so a planner can reason about 2,000 tasks while reading 0 of them.
1948
+ // (3) Rows are compact by default; `include` widens them. (4) Served from the
1949
+ // edge snapshot (KV memo of the board, `asOf` stamps it) — never a live TypeDB
1950
+ // read per call (CLAUDE.md § The brain and the edge). `fresh: true` asks for a
1951
+ // rebuild and is honoured at most once per memo window.
1952
+ "tasks:board": receiver({
1953
+ receiver: "tasks:board",
1954
+ surfaces: { mcp: true },
1955
+ 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.",
1956
+ request: z.object({
1957
+ workspace: z.string().optional().describe("Group slug. Omit for your own. Honoured only if you may read it."),
1958
+ scope: z.enum(["own", "tree"]).optional().describe("tree = this group AND every descendant group (the CEO / agency lens). Default own."),
1959
+ status: z.union([z.string(), z.array(z.string())]).optional()
1960
+ .describe("open | blocked | picked | done | verified | failed | dissolved, one or many. 'active' = open+blocked+picked (the default). 'all' = every status."),
1961
+ tags: z.array(z.string()).optional().describe("Row must carry EVERY tag (AND)."),
1962
+ anyTags: z.array(z.string()).optional().describe("Row must carry AT LEAST ONE of these tags (OR)."),
1963
+ assignee: z.string().optional().describe("Actor slug. '' = unassigned only."),
1964
+ parent: z.string().optional().describe("Only direct children of this task id. '' = top-level rows (no parent)."),
1965
+ search: z.string().optional().describe("Case-insensitive substring on the task name."),
1966
+ 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."),
1967
+ include: z.array(z.enum(["notes", "graph", "dates", "thread"])).optional()
1968
+ .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."),
1969
+ 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."),
1970
+ sort: z.enum(["priority", "due-at", "updated-at", "created-at", "name"]).optional().describe("Default priority (desc)."),
1971
+ dir: z.enum(["asc", "desc"]).optional(),
1972
+ limit: z.number().int().min(1).max(2000).optional().describe("Rows per page. Default 500, max 2000."),
1973
+ cursor: z.string().optional().describe("The nextCursor from the previous page."),
1974
+ fresh: z.boolean().optional().describe("Rebuild the snapshot instead of reading the memo. Rate-limited to once per memo window."),
1975
+ }),
1976
+ response: z.object({
1977
+ ok: z.boolean(),
1978
+ workspace: z.string().optional(),
1979
+ workspaces: z.array(z.string()).optional().describe("Every group the rows were read from (scope:'tree')."),
1980
+ asOf: z.string().optional().describe("ISO time the snapshot was built. Rows are at most this stale."),
1981
+ cached: z.boolean().optional(),
1982
+ 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."),
1983
+ returned: z.number().optional(),
1984
+ nextCursor: z.string().optional().describe("Present iff more rows match. Absent = you have them all."),
1985
+ truncated: z.object({
1986
+ rows: z.number().optional().describe("The snapshot hit its row budget; this many unique rows were read. total is a floor."),
1987
+ edges: z.boolean().optional().describe("parent/blockedBy edges were capped — a row may read as unblocked or orphaned when it is not."),
1988
+ tags: z.number().optional().describe("This many returned rows may be missing tags."),
1989
+ }).optional().describe("Absent = nothing was cut. Present = say so before you plan on it."),
1990
+ summary: z.object({
1991
+ byStatus: z.record(z.string(), z.number()),
1992
+ byTag: z.record(z.string(), z.number()).describe("Bare tags only (no workspace:/slug:/@ namespaces), top 100 by count."),
1993
+ byAssignee: z.record(z.string(), z.number()).describe("'' key = unassigned."),
1994
+ byWorkspace: z.record(z.string(), z.number()),
1995
+ ready: z.number().describe("Open with every blocker closed — claimable now."),
1996
+ blocked: z.number().describe("Has at least one open blocker, or status blocked."),
1997
+ overdue: z.number(),
1998
+ unassigned: z.number(),
1999
+ orphans: z.number().describe("No parent — unreachable from any plan's containment walk."),
2000
+ noNotes: z.number().describe("No prose goal — the rows a puller cannot act on."),
2001
+ readyIds: z.array(z.string()).describe("Top 50 ready rows by priority."),
2002
+ blockedIds: z.array(z.string()).describe("Top 50 blocked rows by priority."),
2003
+ }).optional(),
2004
+ tasks: z.array(z.object({
2005
+ tid: z.string(),
2006
+ name: z.string(),
2007
+ status: z.string(),
2008
+ priority: z.number(),
2009
+ tags: z.array(z.string()).describe("Bare tags; workspace:/@assignee are lifted into their own fields."),
2010
+ assignee: z.string().optional(),
2011
+ workspace: z.string(),
2012
+ parent: z.string().optional(),
2013
+ blockedBy: z.array(z.string()).optional(),
2014
+ openBlockers: z.number().optional().describe("How many of blockedBy are not yet done/verified/dissolved."),
2015
+ ready: z.boolean(),
2016
+ dueAt: z.string().optional(),
2017
+ overdue: z.boolean().optional(),
2018
+ // include: dates
2019
+ startAt: z.string().optional(),
2020
+ createdAt: z.string().optional(),
2021
+ closedAt: z.string().optional(),
2022
+ // include: notes
2023
+ notes: z.string().optional(),
2024
+ // include: graph
2025
+ children: z.array(z.string()).optional(),
2026
+ blocks: z.array(z.string()).optional(),
2027
+ // include: thread — the same D1 conversation tasks:comment writes and /in lists
2028
+ thread: z.object({
2029
+ threadId: z.string().optional().describe("The inbox thread id; absent = nobody has commented yet."),
2030
+ comments: z.number(),
2031
+ lastAt: z.string().optional(),
2032
+ lastAuthor: z.string().optional(),
2033
+ lastBody: z.string().optional().describe("First 280 chars of the latest message."),
2034
+ }).optional(),
2035
+ })).optional(),
2036
+ error: z.string().optional(),
2037
+ }),
2038
+ effect: "ask", idempotent: true, auth: "member",
2039
+ examples: [
2040
+ { workspace: "one", view: "summary" },
2041
+ { workspace: "one", scope: "tree", status: "active", ready: true, limit: 100 },
2042
+ { workspace: "one", anyTags: ["launch", "story"], include: ["notes", "graph"] },
2043
+ ],
2044
+ }),
2045
+ // tasks:bulk — the ONE-REQUEST door for N task mutations.
2046
+ //
2047
+ // Every other task write here takes a single `tid`, so refining a 595-row board
2048
+ // (measured, workspace `one`, 2026-09-11) costs 595+ round trips. That is the
2049
+ // reason the board has never been refined. This takes up to 25 rows and reports
2050
+ // a receipt PER ROW — never a single top-level ok that hides a partial run.
2051
+ //
2052
+ // It reimplements no write: the resolver loops and calls the existing
2053
+ // single-tid resolvers with the SAME attested ctx, so authority, the status
2054
+ // gates and the D1 tag mirror are all inherited rather than re-derived. The
2055
+ // `workspace` field (envelope or per row) is a REQUEST, exactly as on every
2056
+ // single-tid write — effectiveWorkspace downgrades it when the caller is
2057
+ // neither staff nor in control of it.
2058
+ "tasks:bulk": receiver({
2059
+ receiver: "tasks:bulk",
2060
+ surfaces: { mcp: true },
2061
+ 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 — you see the matched tids before anything moves; 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.",
2062
+ request: z.object({
2063
+ edits: z.array(z.object({
2064
+ tid: z.string(),
2065
+ title: z.string().optional(),
2066
+ status: z.string().optional().describe("open | blocked | picked | done | verified | failed | dissolved"),
2067
+ priority: z.number().optional().describe("1-100 slider, same scale as tasks:priority"),
2068
+ notes: z.string().optional().describe("Prose goal; empty string clears"),
2069
+ 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."),
2070
+ assignee: z.string().optional().describe("Actor slug; empty string unassigns"),
2071
+ addTags: z.array(z.string()).optional().describe("Bare words only — no ':' and no leading '@'"),
2072
+ removeTags: z.array(z.string()).optional(),
2073
+ dueAt: z.string().nullable().optional().describe("ISO date; null or '' clears. Same semantics as tasks:schedule"),
2074
+ startAt: z.string().nullable().optional(),
2075
+ addBlockedBy: z.array(z.string()).optional().describe("Task ids (or `ref`s from `creates`) this row must wait for — tasks:depend per id"),
2076
+ removeBlockedBy: z.array(z.string()).optional().describe("tasks:undepend per id"),
2077
+ 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"),
2078
+ workspace: z.string().optional().describe("Per-row override of the envelope workspace. A request, never a grant."),
2079
+ })).max(25).optional(),
2080
+ creates: z.array(z.object({
2081
+ 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."),
2082
+ title: z.string(),
2083
+ notes: z.string().optional(),
2084
+ tags: z.array(z.string()).optional(),
2085
+ assignee: z.string().optional(),
2086
+ priority: z.number().optional(),
2087
+ parent: z.string().optional().describe("Existing task id or a ref created EARLIER in this array"),
2088
+ blockedBy: z.array(z.string()).optional().describe("Existing task ids or refs created earlier in this array"),
2089
+ dueAt: z.string().optional(),
2090
+ workspace: z.string().optional(),
2091
+ })).max(25).optional(),
2092
+ where: z.object({
2093
+ status: z.union([z.string(), z.array(z.string())]).optional(),
2094
+ tags: z.array(z.string()).optional(),
2095
+ anyTags: z.array(z.string()).optional(),
2096
+ assignee: z.string().optional(),
2097
+ parent: z.string().optional(),
2098
+ search: z.string().optional(),
2099
+ ready: z.boolean().optional(),
2100
+ scope: z.enum(["own", "tree"]).optional(),
2101
+ }).optional().describe("A tasks:board filter. Requires `set`. Matches are read from the same snapshot tasks:board serves."),
2102
+ set: z.object({
2103
+ status: z.string().optional(),
2104
+ priority: z.number().optional(),
2105
+ assignee: z.string().optional(),
2106
+ addTags: z.array(z.string()).optional(),
2107
+ removeTags: z.array(z.string()).optional(),
2108
+ dueAt: z.string().nullable().optional(),
2109
+ comment: z.string().optional(),
2110
+ }).optional().describe("The ONE edit applied to every `where` match. title/notes are deliberately absent — those are per-row."),
2111
+ dryRun: z.boolean().optional().describe("For `where`: defaults TRUE. Pass false to apply. Ignored by edits/creates."),
2112
+ cursor: z.string().optional().describe("For `where`: the nextCursor from the previous call"),
2113
+ workspace: z.string().optional().describe("Workspace to act in, for every row. Honoured only if you are staff or control it."),
2114
+ }).refine((v) => (v.edits?.length ?? 0) + (v.creates?.length ?? 0) > 0 || (!!v.where && !!v.set), {
2115
+ message: "send edits, creates, or where+set",
2116
+ path: ["edits"],
2117
+ }).refine((v) => !v.where === !v.set, { message: "where and set go together", path: ["where"] }),
2118
+ response: z.object({
2119
+ ok: z.boolean().describe("The CALL's outcome, not the rows'. Read applied/failed."),
2120
+ total: z.number().optional(),
2121
+ attempted: z.number().optional(),
2122
+ applied: z.number().optional(),
2123
+ failed: z.number().optional(),
2124
+ notAttempted: z.number().optional(),
2125
+ error: z.string().optional(),
2126
+ detail: z.string().optional(),
2127
+ created: z.record(z.string(), z.string()).optional().describe("ref → tid for every `creates` row that landed"),
2128
+ matched: z.number().optional().describe("`where`: rows the filter matched in total, across all pages"),
2129
+ matchedIds: z.array(z.string()).optional().describe("`where`: the tids this call covers (the ones it would apply, on dryRun)"),
2130
+ dryRun: z.boolean().optional(),
2131
+ nextCursor: z.string().optional().describe("`where`: present iff more matches remain — resend the same call with this cursor"),
2132
+ results: z.array(z.object({
2133
+ tid: z.string(),
2134
+ ref: z.string().optional(),
2135
+ ok: z.boolean(),
2136
+ reason: z.string().optional(),
2137
+ retryable: z.boolean().optional(),
2138
+ fields: z.array(z.object({
2139
+ field: z.string(),
2140
+ ok: z.boolean(),
2141
+ reason: z.string().optional(),
2142
+ detail: z.string().optional(),
2143
+ retryable: z.boolean().optional(),
2144
+ })).optional(),
2145
+ })).optional(),
2146
+ }),
2147
+ effect: "ask", idempotent: false, auth: "member",
2148
+ examples: [
2149
+ { edits: [{ tid: "task:abc", status: "dissolved", comment: "Superseded by task:def" }, { tid: "task:def", notes: "Done when X", addTags: ["planning"] }], workspace: "one" },
2150
+ { 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" },
2151
+ { where: { status: "open", tags: ["launch"], assignee: "" }, set: { assignee: "cmo", comment: "Routing unowned launch work to marketing" }, dryRun: true, workspace: "one" },
2152
+ { 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" },
2153
+ ],
2154
+ }),
1565
2155
  "tasks:follow": receiver({
1566
2156
  receiver: "tasks:follow",
1567
2157
  surfaces: { mcp: true },
@@ -2008,10 +2598,12 @@ export const RECEIVERS = {
2008
2598
  request: z.object({
2009
2599
  limit: z.number().optional(), since: z.number().optional(),
2010
2600
  from: z.number().optional(), to: z.number().optional(),
2601
+ intent: z.string().optional(),
2011
2602
  }),
2012
2603
  response: z.array(z.object({
2013
2604
  id: z.string(), from: z.string(), to: z.string(), skill: z.string().optional(),
2014
2605
  outcome: z.enum(["success", "failure", "timeout"]), revenue: z.number(), ts: z.number(),
2606
+ intent: z.string().optional(),
2015
2607
  })),
2016
2608
  effect: "ask", cost: "free", idempotent: true,
2017
2609
  }),
@@ -2126,6 +2718,78 @@ export const RECEIVERS = {
2126
2718
  effect: "ask", cost: "free", idempotent: true,
2127
2719
  examples: [{ feature: "premium" }],
2128
2720
  }),
2721
+ // ════════════════════════════════════════════════════════════════════════
2722
+ // id: — one door for every address in the estate. `id:resolve` is a pure
2723
+ // projection over `one.ie/web/src/data/id-inventory.json`, the artifact
2724
+ // `.claude/scripts/id-inventory.mjs` writes and its `--check` regenerates in
2725
+ // memory to fail on drift. NOTHING HERE IS HAND-DECLARED: kind, provenance,
2726
+ // count, id_prefix, summary and source are each read off that file, so this
2727
+ // door cannot claim a population the generator never counted.
2728
+ //
2729
+ // Seven kinds — signal · skill · workflow · component · doc · function ·
2730
+ // thing. `thing` is the LOCKED dimension-3 word: a ref whose prefix is
2731
+ // `object:` is REFUSED, never silently rewritten, because a door that
2732
+ // rewrites teaches callers a name the substrate does not have. Functions
2733
+ // address as `fn:` (the estate's existing address — `fn:run`'s FN_MAP),
2734
+ // never `fun:`.
2735
+ // ════════════════════════════════════════════════════════════════════════
2736
+ "id:resolve": receiver({
2737
+ receiver: "id:resolve",
2738
+ 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",
2739
+ request: z.object({
2740
+ // The grammar `TID_RE` already admits (resolvers/factory.ts:32): MINTED
2741
+ // `<type>:<24-hex>` or DERIVED `<type>:<scope>[:<n>][/<part>]`. A bare
2742
+ // receiver name is its own address — `factory:size` resolves as a signal,
2743
+ // not as a `factory` scope, because an exact registry name is matched
2744
+ // before any prefix is read (21 receiver names would collide otherwise).
2745
+ ref: z.string().min(1).max(128),
2746
+ }),
2747
+ // `factory:size`'s idiom deliberately: one flat schema, `ok: z.boolean()`,
2748
+ // everything else optional — never a discriminated union. A refusal is the
2749
+ // same shape as an answer, so a caller reads `ok` and nothing else.
2750
+ response: z.object({
2751
+ ok: z.boolean(),
2752
+ ref: z.string().optional(),
2753
+ kind: z.string().optional(),
2754
+ // Lexical, per trace.ts:26-31 — the last colon segment decides. Never
2755
+ // temporal: `attempt:<hex>:<n>` is DERIVED because its last segment is an
2756
+ // ordinal, which is the same answer `traceIdMintedAt` already gives.
2757
+ lane: z.enum(["minted", "derived"]).optional(),
2758
+ name: z.string().optional(),
2759
+ title: z.string().optional(),
2760
+ summary: z.string().optional(),
2761
+ source: z.string().optional(),
2762
+ // BARE, never composed — the inventory stores `fn`, and `fn:<name>` is
2763
+ // built at read time. null for `signal`: a receiver's name IS its address.
2764
+ id_prefix: z.string().nullable().optional(),
2765
+ // Read off the kind record, not asserted here. `generated` for the five
2766
+ // counted kinds; `graph` is what `workflow` and `thing` carry, and they
2767
+ // hold no entries, so they answer `unknown_ref` until the graph is walked.
2768
+ provenance: z.string().optional(),
2769
+ // The kind's population, or null where the inventory refused to count.
2770
+ // null is UNRUN, never zero.
2771
+ count: z.number().nullable().optional(),
2772
+ // The commit the inventory was generated at — the answer's receipt.
2773
+ generated_at: z.string().optional(),
2774
+ error: z.string().optional(),
2775
+ }),
2776
+ // `auth: "authenticated"` — C20, the auditor's second finding. This door has
2777
+ // no `auth:` field until now, and an ABSENT label is the one case
2778
+ // `authClassFor` answers 'open' for (bind-receiver.ts:119), while the doc
2779
+ // five lines above it says an unlisted label falls to 'authenticated'. So
2780
+ // the declaration and the default disagreed, and this receiver was on the
2781
+ // wrong side of it: what it projects IS the web's internals — every id,
2782
+ // summary, source path and population count in the estate, from one
2783
+ // uncredentialed POST. 320 of 335 receivers share the absent label; the
2784
+ // default is NOT inverted this cycle (that needs the census bind-receiver.ts
2785
+ // now logs), so this one says so explicitly instead of waiting for it.
2786
+ //
2787
+ // Costs the contract nothing: text/story.md:24's accept sends
2788
+ // GATEWAY_API_KEY, which `isVerifiedServiceCaller` attests, so the promise's
2789
+ // own check passes unchanged.
2790
+ effect: "ask", auth: "authenticated", cost: "free", idempotent: true,
2791
+ examples: [{ ref: "factory:size" }, { ref: "fn:uncovered" }],
2792
+ }),
2129
2793
  "plugins:list": receiver({
2130
2794
  receiver: "plugins:list",
2131
2795
  summary: "List all available ONE plugins with their feature key, price, and description",
@@ -2294,6 +2958,20 @@ export const RECEIVERS = {
2294
2958
  content: z.string(),
2295
2959
  ts: z.number().optional(),
2296
2960
  tags: z.array(z.string()).optional(),
2961
+ // The resolver implements this branch (messaging.ts) — an agent mirroring
2962
+ // its own reply back into the inbox writes role "assistant", which the
2963
+ // resolver turns into authorKind "agent". Undeclared, zod stripped it
2964
+ // before the resolver ever saw it, so the branch was unreachable for
2965
+ // EVERY gateway caller and every agent post landed as role "user" /
2966
+ // authorKind "customer". `authorKind` stays UNDECLARED deliberately: its
2967
+ // only reachable value is "staff", which is a caller naming its own
2968
+ // author (resolver-body-actorId-not-identity) and needs an attestation
2969
+ // decision, not a schema line.
2970
+ role: z.enum(["user", "assistant"]).optional(),
2971
+ // The structured envelope forwarded from `/signal/:group` — see
2972
+ // `web/src/lib/signal-meta.ts SignalMeta`. Loose on purpose (see
2973
+ // tasks:announce's `meta` field for why).
2974
+ meta: z.record(z.string(), z.unknown()).optional(),
2297
2975
  }),
2298
2976
  response: z.object({
2299
2977
  ok: z.boolean(),
@@ -2506,6 +3184,7 @@ export const RECEIVERS = {
2506
3184
  response: z.object({
2507
3185
  ok: z.boolean(),
2508
3186
  delivered: z.string().optional(), // 'web' | 'telegram' | 'discord' | 'failed'
3187
+ deliveryError: z.string().optional(), // present iff delivered==='failed' — the platform's own words; the reply IS still persisted
2509
3188
  kind: z.string().optional(),
2510
3189
  error: z.string().optional(),
2511
3190
  }),
@@ -3039,6 +3718,9 @@ export const RECEIVERS = {
3039
3718
  actor: z.string(), chain: z.string(), address: z.string(), balance: z.string().nullable(),
3040
3719
  })).optional(),
3041
3720
  addresses: z.array(z.object({ actor: z.string(), chain: z.string(), address: z.string() })).optional(),
3721
+ // The per-agent PROJECTION of the on-chain ceiling (text/launch.md §0d).
3722
+ // Zod strips unknown keys, so a field absent here never reaches AgentCaps.
3723
+ agentCaps: z.array(z.record(z.string(), z.unknown())).optional(),
3042
3724
  delegated: z.array(z.record(z.string(), z.unknown())).optional(),
3043
3725
  error: z.string().optional(),
3044
3726
  }),
@@ -3063,6 +3745,37 @@ export const RECEIVERS = {
3063
3745
  effect: "ask", cost: "free", reversible: true, idempotent: true, settles: "none", auth: "member",
3064
3746
  examples: [{ limit: 20 }, { workspace: "acme", limit: 100 }],
3065
3747
  }),
3748
+ // wallet:transfer — INTERNAL credits only. TRANSFER, NEVER MINT: a
3749
+ // solvency-checked debit and a credit that sum to zero, so a compromised caller
3750
+ // can move credits around but cannot create them. Bounded by the payer's cap so
3751
+ // an agent cannot transfer its way past its ceiling. Deliberately NOT
3752
+ // `surfaces: { mcp: true }` — it never touches a chain and settles in the
3753
+ // ledger, so it declares `settles: "offchain"` and stays reversible by a
3754
+ // compensating transfer, unlike wallet:send.
3755
+ "wallet:transfer": receiver({
3756
+ receiver: "wallet:transfer",
3757
+ summary: "Move credits between workspaces inside the ledger — solvency-checked, cap-bounded, zero-sum. Never mints; never touches a chain",
3758
+ request: z.object({
3759
+ to: z.string().describe("Recipient workspace slug"),
3760
+ amount: z.number().describe("Credits; must be > 0 and an integer"),
3761
+ workspace: z.string().optional().describe("Payer workspace; falls back to `from`, then the attested caller"),
3762
+ from: z.string().optional().describe("Alias for `workspace` — the source the authority walk runs against"),
3763
+ transferId: z.string().optional().describe("Idempotency key; both legs share it as rail_ref"),
3764
+ memo: z.string().optional(),
3765
+ }),
3766
+ response: z.object({
3767
+ ok: z.boolean(),
3768
+ from: z.string().optional(),
3769
+ to: z.string().optional(),
3770
+ amount: z.number().optional(),
3771
+ transferId: z.string().optional(),
3772
+ error: z.string().optional().describe("forbidden | insufficient_credits | cap_exceeded | invalid_amount"),
3773
+ }),
3774
+ // Zero-sum inside the credit ledger — real value, settled OFFCHAIN (never on
3775
+ // chain), reversible by a compensating transfer. Idempotent on `transferId`.
3776
+ effect: "ask", cost: "free", reversible: true, idempotent: true, settles: "offchain", auth: "manage_workspace",
3777
+ examples: [{ to: "acme", amount: 500 }],
3778
+ }),
3066
3779
  // The caller signs and broadcasts the transfer CLIENT-SIDE and passes the
3067
3780
  // resulting `paymentTx`; this composes pay.one.ie's create → quote → claim and
3068
3781
  // never holds a key. It VERIFIES that an on-chain transfer settled, so it
@@ -3252,7 +3965,14 @@ export const RECEIVERS = {
3252
3965
  surfaces: { mcp: true },
3253
3966
  summary: "List a workspace's pages with status",
3254
3967
  request: z.object({ slug: z.string() }),
3255
- response: z.object({ pages: z.array(z.object({ slug: z.string(), title: z.string(), status: z.string() })) }),
3968
+ response: z.object({ pages: z.array(z.object({
3969
+ slug: z.string(),
3970
+ title: z.string(),
3971
+ status: z.string(),
3972
+ url: z.string().optional().describe("The page's public path, /p/<slug>"),
3973
+ updated_at: z.number().optional().describe("Last modified, epoch ms"),
3974
+ 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."),
3975
+ })) }),
3256
3976
  effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
3257
3977
  }),
3258
3978
  "pages:publish": receiver({
@@ -3269,6 +3989,55 @@ export const RECEIVERS = {
3269
3989
  response: z.object({ ok: z.boolean(), published: z.number().optional(), error: z.string().optional() }),
3270
3990
  effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
3271
3991
  }),
3992
+ // WHY THIS IS DECLARED. `pages:delete` shipped as a resolver with no registry
3993
+ // row, so it reached the handler only through the undeclared-receiver escape
3994
+ // hatch — no edge zod parsing, no auth floor. Its two live callers send
3995
+ // `{slug, page}` (`scripts/demo-instantiate-movers.ts`) and
3996
+ // `{slug, actorId, page}` (`channels/src/tools/pages.ts`). `actorId` is NOT
3997
+ // declared here on purpose: `pagesDenyReason` authorizes off `ctx` alone and
3998
+ // has never read it, so declaring it would publish a field nothing consumes
3999
+ // and invite a caller to think it authorizes something.
4000
+ //
4001
+ // `auth: "member"` matches every sibling in this family. A tighter floor
4002
+ // reasoned out fresh would break the channels tool, and the break would look
4003
+ // like a bug in whatever called it.
4004
+ "pages:delete": receiver({
4005
+ receiver: "pages:delete",
4006
+ 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.",
4007
+ request: z.object({ slug: z.string(), page: z.string() }),
4008
+ response: z.object({ ok: z.boolean(), slug: z.string().optional(), title: z.string().optional(), error: z.string().optional() }),
4009
+ effect: "ask", cost: "free", reversible: false, idempotent: true, auth: "member",
4010
+ }),
4011
+ "pages:unpublish": receiver({
4012
+ receiver: "pages:unpublish",
4013
+ 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.",
4014
+ request: z.object({ slug: z.string(), page: z.string() }),
4015
+ 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() }),
4016
+ effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
4017
+ }),
4018
+ "pages:bulk": receiver({
4019
+ receiver: "pages:bulk",
4020
+ summary: "Apply ONE op (publish | unpublish | delete) to an EXPLICIT list of pages — one authority walk, one round trip, a per-row answer.",
4021
+ request: z.object({
4022
+ slug: z.string(),
4023
+ op: z.enum(["publish", "unpublish", "delete"]),
4024
+ 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."),
4025
+ }),
4026
+ response: z.object({
4027
+ 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."),
4028
+ op: z.string().optional(),
4029
+ requested: z.number().optional().describe("Rows attempted, after de-duplication."),
4030
+ changed: z.number().optional().describe("Rows the op actually changed. This is the number to quote."),
4031
+ failed: z.number().optional().describe("requested - changed. Present even when 0, so a caller cannot miss it."),
4032
+ results: z.array(z.object({
4033
+ slug: z.string(), ok: z.boolean(), title: z.string().optional(), error: z.string().optional(),
4034
+ })).optional().describe("Per-row outcome, in the order attempted. A failed row names its own reason (`not_found`, `no_db`)."),
4035
+ error: z.string().optional(),
4036
+ }),
4037
+ // `reversible: false` because delete is one of the three ops and the
4038
+ // envelope cannot be conditional. publish/unpublish are each other's undo.
4039
+ effect: "ask", cost: "free", reversible: false, idempotent: true, auth: "member",
4040
+ }),
3272
4041
  "pages:fill-pack": receiver({
3273
4042
  receiver: "pages:fill-pack",
3274
4043
  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.",
@@ -3912,7 +4681,7 @@ export const RECEIVERS = {
3912
4681
  receiver: "workflow:runs",
3913
4682
  surfaces: { mcp: true },
3914
4683
  summary: "List recent runs of a workflow (D1 workflow_run) for the monitor + history surfaces",
3915
- request: z.object({ workflowId: z.string(), runId: z.string().optional(), limit: z.number().int().min(1).max(200).optional() }),
4684
+ 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() }),
3916
4685
  response: z.object({
3917
4686
  runs: z.array(z.object({
3918
4687
  id: z.string(), status: z.string(),
@@ -3930,7 +4699,7 @@ export const RECEIVERS = {
3930
4699
  "workflow:step-stats": receiver({
3931
4700
  receiver: "workflow:step-stats",
3932
4701
  surfaces: { mcp: true },
3933
- summary: "Per-step run counts and latency for one workflow (aggregated over D1 workflow_run_event) — what the canvas prints on each step card",
4702
+ 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",
3934
4703
  request: z.object({ workflowId: z.string(), sinceMs: z.number().int().optional() }),
3935
4704
  response: z.object({
3936
4705
  stats: z.array(z.object({
@@ -3938,8 +4707,28 @@ export const RECEIVERS = {
3938
4707
  runs: z.number().int(),
3939
4708
  failures: z.number().int(),
3940
4709
  avgMs: z.number().nullable(),
4710
+ minMs: z.number().nullable(),
3941
4711
  maxMs: z.number().nullable(),
4712
+ /** Total ms this step has spent across every completed run. */
4713
+ totalMs: z.number(),
4714
+ /** This step's slice of the whole workflow's execution time, 0-1 — which
4715
+ * step is the bottleneck. null when nothing has completed yet (never 0:
4716
+ * no share of nothing is not "0% of the time"). */
4717
+ share: z.number().nullable(),
3942
4718
  lastAt: z.number().nullable(),
4719
+ // The step's last few events, newest first — what it SAID, not just how
4720
+ // often it ran. `text` is already truncated server-side; a card that wants
4721
+ // the whole payload opens the run monitor.
4722
+ messages: z.array(z.object({
4723
+ kind: z.string(),
4724
+ status: z.string().nullable(),
4725
+ latencyMs: z.number().nullable(),
4726
+ at: z.number(),
4727
+ text: z.string(),
4728
+ })),
4729
+ /** Recent completed latencies, OLDEST → NEWEST, for the card's sparkline.
4730
+ * Empty when the step has completed nothing recently. */
4731
+ series: z.array(z.number()),
3943
4732
  })),
3944
4733
  error: z.string().optional(),
3945
4734
  }),
@@ -5039,6 +5828,232 @@ export const RECEIVERS = {
5039
5828
  effect: "ask", cost: "variable", reversible: true, idempotent: false, auth: "member",
5040
5829
  examples: [{ site_url: "https://one.ie/", start_date: "2026-01-01", end_date: "2026-06-01" }],
5041
5830
  }),
5831
+ // ── promise: — the contract a story signs (resolvers/promises.ts) ──────────
5832
+ // Declared here because a workflow step may only bind a REGISTERED name:
5833
+ // `story-intake` binds both, and campaign-ads-social.test.ts refuses a binding
5834
+ // no receiver implements. `promise:get` is deliberately NOT declared — nothing
5835
+ // binds it and this cycle registers only what a template reaches.
5836
+ "promise:make": receiver({
5837
+ receiver: "promise:make",
5838
+ 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",
5839
+ request: z.object({
5840
+ slug: z.string().describe("Stable id for the promise, e.g. story:<origin>. [A-Za-z0-9_:/-], max 128"),
5841
+ terms: z.string().describe("What is promised — hashed verbatim into terms_hash and never rewritten"),
5842
+ 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"),
5843
+ }),
5844
+ response: z.object({
5845
+ ok: z.boolean(),
5846
+ slug: z.string().optional(),
5847
+ state: z.literal("promised").optional(),
5848
+ terms_hash: z.string().optional(),
5849
+ error: z.string().optional(),
5850
+ }),
5851
+ effect: "ask", cost: "free", auth: "member",
5852
+ reversible: false, idempotent: true, settles: "none",
5853
+ 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" }],
5854
+ }),
5855
+ "promise:settle": receiver({
5856
+ receiver: "promise:settle",
5857
+ 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",
5858
+ request: z.object({
5859
+ slug: z.string(),
5860
+ kept: z.boolean().optional().describe("Defaults to true. false ⇒ settled-broken: resistance += 1, the path decays honestly"),
5861
+ composite: z.number().optional().describe("Rubric composite; scales the mark by 5× when kept"),
5862
+ }),
5863
+ response: z.object({
5864
+ ok: z.boolean(),
5865
+ slug: z.string().optional(),
5866
+ state: z.enum(["settled-kept", "settled-broken", "dissolved"]).optional(),
5867
+ strength: z.number().optional(),
5868
+ resistance: z.number().optional(),
5869
+ error: z.string().optional(),
5870
+ }),
5871
+ effect: "ask", cost: "free", auth: "member",
5872
+ reversible: false, idempotent: false, settles: "none",
5873
+ examples: [{ slug: "story:demo", kept: false }],
5874
+ }),
5875
+ // ── duty: — provision that was PROMISED, recorded as delivered or missed ──
5876
+ // (resolvers/duty.ts · migrations/0245_duties.sql · lib/ehc/duty.ts)
5877
+ //
5878
+ // The family exists because `promise:*` inverts wrong for a statutory duty:
5879
+ // `promises.ts:96` lets the maker settle its own promise, and `:112` defaults
5880
+ // a missing verdict to KEPT. Here the certifier is a DIFFERENT actor, refused
5881
+ // at write time by `CHECK (certifier <> holder)`, and NO caller supplies a
5882
+ // verdict at all — the state is computed from counts and the clock, so a
5883
+ // window that lapsed with nothing recorded reads as `breached`, not as blank.
5884
+ // Spec: text/ehc-substrate-plan.md §3.1.
5885
+ "duty:make": receiver({
5886
+ receiver: "duty:make",
5887
+ 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",
5888
+ request: z.object({
5889
+ slug: z.string().describe("Stable id, e.g. ehc:cyp-005:H2. [A-Za-z0-9_:/-], max 128"),
5890
+ terms: z.string().describe("What is owed — hashed verbatim into terms_hash"),
5891
+ observable: z.string().describe("What ONE delivery looks like: 'a meal, on a date, received' — not 'a meal service was commissioned'"),
5892
+ certifier: z.string().describe("Who may attest delivery. MUST NOT be the holder — the duty-holder never grades its own homework"),
5893
+ owed: z.number().int().describe("How many deliveries are owed. 0 means not yet quantified and computes to state 'unknown', never 'met'"),
5894
+ 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"),
5895
+ }),
5896
+ response: z.object({
5897
+ ok: z.boolean(),
5898
+ slug: z.string().optional(),
5899
+ version: z.number().optional(),
5900
+ state: z.enum(["unknown", "owed", "part-met", "met", "breached"]).optional(),
5901
+ terms_hash: z.string().optional(),
5902
+ certifier: z.string().optional(),
5903
+ error: z.string().optional(),
5904
+ }),
5905
+ effect: "ask", cost: "free", auth: "member",
5906
+ reversible: false, idempotent: true, settles: "none",
5907
+ 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 }],
5908
+ }),
5909
+ "duty:amend": receiver({
5910
+ receiver: "duty:amend",
5911
+ 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",
5912
+ request: z.object({
5913
+ slug: z.string(),
5914
+ terms: z.string().optional(),
5915
+ observable: z.string().optional(),
5916
+ certifier: z.string().optional().describe("Still must not equal the holder"),
5917
+ owed: z.number().int().optional(),
5918
+ due_at: z.number().nullable().optional(),
5919
+ }),
5920
+ response: z.object({
5921
+ ok: z.boolean(),
5922
+ slug: z.string().optional(),
5923
+ version: z.number().optional(),
5924
+ supersedes: z.string().nullable().optional(),
5925
+ state: z.enum(["unknown", "owed", "part-met", "met", "breached"]).optional(),
5926
+ terms_hash: z.string().optional(),
5927
+ blocked_on: z.string().optional().describe("'tribunal-rung' — the missing authority, named rather than silently allowed"),
5928
+ error: z.string().optional(),
5929
+ }),
5930
+ effect: "ask", cost: "free", auth: "member",
5931
+ reversible: false, idempotent: false, settles: "none",
5932
+ examples: [{ slug: "ehc:cyp-005:H2", owed: 190, terms: "A hot meal every weekday of the YEAR, holidays included" }],
5933
+ }),
5934
+ "duty:evidence": receiver({
5935
+ receiver: "duty:evidence",
5936
+ 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",
5937
+ request: z.object({
5938
+ slug: z.string(),
5939
+ ref: z.string().describe("The delivery, named: 'meal 2026-09-12', 'SLT session note wk37'"),
5940
+ version: z.number().int().optional().describe("Defaults to the current version; an older one is refused, not back-filed quietly"),
5941
+ }),
5942
+ response: z.object({
5943
+ ok: z.boolean(),
5944
+ slug: z.string().optional(),
5945
+ version: z.number().optional(),
5946
+ id: z.string().optional(),
5947
+ evidenced: z.number().optional().describe("A COUNT over duty_evidence, never a stored column"),
5948
+ owed: z.number().optional(),
5949
+ state: z.enum(["unknown", "owed", "part-met", "met", "breached"]).optional(),
5950
+ recorded: z.boolean().optional().describe("false ⇒ this exact delivery was already on record"),
5951
+ error: z.string().optional(),
5952
+ }),
5953
+ effect: "ask", cost: "free", auth: "member",
5954
+ reversible: false, idempotent: true, settles: "none",
5955
+ examples: [{ slug: "ehc:cyp-005:H2", ref: "hot meal received 2026-09-12" }],
5956
+ }),
5957
+ "duty:breach": receiver({
5958
+ receiver: "duty:breach",
5959
+ 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`",
5960
+ request: z.object({ slug: z.string() }),
5961
+ response: z.object({
5962
+ ok: z.boolean(),
5963
+ slug: z.string().optional(),
5964
+ version: z.number().optional(),
5965
+ state: z.enum(["unknown", "owed", "part-met", "met", "breached"]).optional(),
5966
+ changed: z.boolean().optional(),
5967
+ owed: z.number().optional(),
5968
+ evidenced: z.number().optional(),
5969
+ due_at: z.number().nullable().optional(),
5970
+ holder: z.string().optional(),
5971
+ breached: z.boolean().optional(),
5972
+ error: z.string().optional(),
5973
+ }),
5974
+ effect: "ask", cost: "free", auth: "member",
5975
+ reversible: true, idempotent: true, settles: "none",
5976
+ examples: [{ slug: "ehc:cyp-005:H2" }],
5977
+ }),
5978
+ "duty:get": receiver({
5979
+ receiver: "duty:get",
5980
+ 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",
5981
+ request: z.object({ slug: z.string() }),
5982
+ response: z.object({
5983
+ ok: z.boolean(),
5984
+ slug: z.string().optional(),
5985
+ version: z.number().optional(),
5986
+ supersedes: z.string().nullable().optional(),
5987
+ holder: z.string().optional(),
5988
+ certifier: z.string().optional(),
5989
+ terms: z.string().optional(),
5990
+ observable: z.string().optional(),
5991
+ owed: z.number().optional(),
5992
+ evidenced: z.number().optional(),
5993
+ due_at: z.number().nullable().optional(),
5994
+ state: z.enum(["unknown", "owed", "part-met", "met", "breached"]).optional(),
5995
+ stored_state: z.string().optional().describe("The cached column. It should equal `state`; a divergence means nothing has recomputed since the window closed"),
5996
+ error: z.string().optional(),
5997
+ }),
5998
+ effect: "ask", cost: "free", auth: "member",
5999
+ reversible: true, idempotent: true, settles: "none",
6000
+ examples: [{ slug: "ehc:cyp-005:H2" }],
6001
+ }),
6002
+ // ── ui: — an agent's hand on the operator's own browser ───────────────────
6003
+ //
6004
+ // The ONLY receiver in the registry whose effect is a MOVE OF A HUMAN'S
6005
+ // SCREEN, so it is the only one where "authority off ctx, never the payload"
6006
+ // is not hygiene but the whole feature. Given a target-viewer field, this
6007
+ // receiver is a machine for pushing any actor's reader anywhere — so there is
6008
+ // no such field, and a payload that names one is REFUSED rather than ignored
6009
+ // (silently stripping it would answer ok:true to a caller whose model of the
6010
+ // world is wrong, and teach it the call worked).
6011
+ "ui:navigate": receiver({
6012
+ receiver: "ui:navigate",
6013
+ 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.",
6014
+ request: z
6015
+ .object({
6016
+ href: z
6017
+ .string()
6018
+ .min(1)
6019
+ .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."),
6020
+ reason: z
6021
+ .enum(["select", "command", "agent"])
6022
+ .optional()
6023
+ .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."),
6024
+ replace: z
6025
+ .boolean()
6026
+ .optional()
6027
+ .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."),
6028
+ })
6029
+ .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."),
6030
+ response: z.object({
6031
+ ok: z.boolean(),
6032
+ href: z.string().optional().describe("The href as accepted — byte-identical to the request, never a rewritten one"),
6033
+ replace: z.boolean().optional(),
6034
+ reason: z
6035
+ .enum(["select", "command", "agent"])
6036
+ .optional()
6037
+ .describe("The request's `reason`, echoed unchanged. Caller-asserted — never evidence of a human"),
6038
+ audience: z
6039
+ .string()
6040
+ .optional()
6041
+ .describe("The caller's OWN attested slug — echoed so a caller can see whose reader it addressed. Never a value the caller supplied"),
6042
+ delivered: z
6043
+ .boolean()
6044
+ .optional()
6045
+ .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"),
6046
+ note: z.string().optional(),
6047
+ error: z.string().optional(),
6048
+ }),
6049
+ effect: "ask",
6050
+ cost: "free",
6051
+ auth: "session",
6052
+ reversible: true,
6053
+ idempotent: false,
6054
+ settles: "none",
6055
+ examples: [{ href: "/u/one/in", reason: "agent" }],
6056
+ }),
5042
6057
  };
5043
6058
  /**
5044
6059
  * RECIPES — the four agent journeys as typed, ordered receiver sequences (C6).