@githolon/dsl 0.103.19 → 0.104.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.
@@ -27,15 +27,24 @@
27
27
  * // capability "llm.complete" (the CapabilityBinding the executor must hold)
28
28
  * ```
29
29
  *
30
- * THE RECEIPT-FIELD MECHANISM (stated honestly — the sheet's §4 comment): the
31
- * complete Receipt's payload is the framework-FIXED `{requestId, resultRef,
32
- * completedAt}`; typed receipt VALUES ride INSIDE `resultRef` as a JSON object,
33
- * and the generated `completeResult` ops decode them into the declared typed
34
- * fields (per-field zod-validated IN the plan — a malformed executor result is a
35
- * typed plan-halt refusal, never a silent partial fold). Receipt fields are
36
- * auto-`.optional()` on the aggregate (absent while `requested` — the
37
- * read-decode discipline). Large bodies belong in content-addressed
38
- * `resultRef`/`resultEvidence` refs, not inline values.
30
+ * THE RECEIPT IS TYPED LAW. The complete Receipt's payload is the envelope
31
+ * `{requestId, completedAt}` MERGED with the declared receipt fields — typed USDA
32
+ * values, exactly as the obligation merges its request params. Each declared field
33
+ * becomes a first-class contract field in the emitted law (its own stable id and
34
+ * `nomos:type`), so the kernel can read it, the evolve gate governs it, and a
35
+ * wrong-typed or missing value is refused at the payload boundary.
36
+ *
37
+ * It previously travelled as a JSON object stringified into `resultRef` and re-parsed
38
+ * in the plan. That made the receipt half the one place a declared, typed shape
39
+ * degraded to an opaque string: invisible to the law, unindexable, and silently
40
+ * partial when a provider misspelled a key. `resultRef` now means only what its name
41
+ * says — the address of a produced ARTIFACT — and is required solely for a capability
42
+ * that declares no receipt fields, so a Receipt can never report nothing.
43
+ *
44
+ * Receipt fields are auto-`.optional()` on the AGGREGATE (absent while `requested` —
45
+ * the read-decode discipline) but required on the PAYLOAD unless the author declares
46
+ * them optional: a partial result is something you declare, not something you assume.
47
+ * Large bodies still belong in content-addressed `resultRef`/`resultEvidence` refs.
39
48
  *
40
49
  * Naming: the capability name uses the shipped binding grammar
41
50
  * (`^[a-z0-9][a-z0-9._-]{0,63}$` — dots legal; marketplace interfaces are
@@ -55,10 +64,41 @@ import {
55
64
  impureCapabilityInstanceId,
56
65
  type ImpureCapability,
57
66
  } from "./impure_capability.js";
67
+ import { key, relationRef, relationType, self, user } from "../relation_schema.js";
68
+ import { directive } from "../directive.js";
69
+ import { RelationTuple, removeTuple, writeTuple } from "./relations.js";
58
70
 
59
71
  /** The shipped CapabilityBinding name grammar (worker + governance law agree). */
60
72
  export const CAPABILITY_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
61
73
 
74
+ /**
75
+ * THE RECEIPT FENCE — who may close an obligation.
76
+ *
77
+ * A Receipt (`complete`/`fail`/`block`/`deadLetter`) is the moment an OUTSIDE effect becomes
78
+ * chain fact, so it is the one write that must prove it came from the provider the work was
79
+ * offered to. The proof is asymmetric and re-derived on every lane: the provider SIGNS its
80
+ * receipt with its DEVICE KEY, the kernel verifies that signature and resolves the author as
81
+ * `key:<hash>`, and this relation says whether that key may close obligations for THIS
82
+ * capability. No bearer token, nothing host-held, nothing that lands in committed state.
83
+ *
84
+ * The object is per-capability (`nomosCapability:<name>`), so a transcription provider cannot
85
+ * close a payments obligation. `user()` is allowed alongside `key()` for an operator closing a
86
+ * receipt by hand under an authenticated identity.
87
+ */
88
+ export const CAPABILITY_PROVIDER_RELATION = relationRef("nomosCapabilityProvider");
89
+
90
+ /** The relation SCHEMA for the fence. Shipped automatically with every `capability()` (via the
91
+ * bundle's `relations` piece) because `.requires(...)` is INERT — it fails OPEN — in a law that
92
+ * carries no relation schema. A fence that silently enforces nothing is worse than none. */
93
+ export const capabilityProviderRelations = relationType("nomosCapability")
94
+ .asGlobal()
95
+ .relation(CAPABILITY_PROVIDER_RELATION, { rewrite: self(), allow: [user(), key()] });
96
+
97
+ /** The per-capability authz object a provider grant is written against. */
98
+ export function capabilityProviderObject(name: string): string {
99
+ return `nomosCapability:${name}`;
100
+ }
101
+
62
102
  /** "llm.complete" → "LlmComplete" — the deterministic id stem. */
63
103
  export function capabilityPascal(name: string): string {
64
104
  return name
@@ -81,7 +121,12 @@ type RequestShape<Req extends Record<string, Field>> = { [K in keyof Req]: Req[K
81
121
  export interface CapabilityDecl<
82
122
  Req extends Record<string, Field>,
83
123
  Rec extends Record<string, Field>,
84
- > extends ImpureCapability<string, RequestShape<Req>, Req & Rec> {
124
+ > extends ImpureCapability<
125
+ string,
126
+ RequestShape<Req>,
127
+ Req & Rec,
128
+ { [K in keyof Rec]: Rec[K]["zod"] }
129
+ > {
85
130
  /** The capability name — the `CapabilityBinding` slot the executor must hold. */
86
131
  readonly name: string;
87
132
  /**
@@ -111,8 +156,14 @@ export interface CapabilityDecl<
111
156
  readonly tasks: QueryDecl;
112
157
  /** The receipt fields' zod object — `{}` when the capability declares none. */
113
158
  readonly receiptSchema: z.ZodObject<{ [K in keyof Rec]: Rec[K]["zod"] }>;
114
- /** Build the `resultRef` JSON for a complete Receipt from typed receipt values. */
115
- readonly encodeResult: (values: { [K in keyof Rec]: z.infer<Rec[K]["zod"]> }) => string;
159
+ /**
160
+ * Validate typed receipt values against the declared receipt shape. A provider spreads the
161
+ * result into the `complete<Pascal>` payload alongside `{requestId, completedAt}`; there is
162
+ * no encode step, because receipt values travel as typed USDA exactly like request params.
163
+ */
164
+ readonly parseResult: (values: { [K in keyof Rec]: z.infer<Rec[K]["zod"]> }) => {
165
+ [K in keyof Rec]: z.infer<Rec[K]["zod"]>;
166
+ };
116
167
  }
117
168
 
118
169
  /**
@@ -128,7 +179,22 @@ export function capability<
128
179
  const Name extends string,
129
180
  Req extends Record<string, Field>,
130
181
  Rec extends Record<string, Field> = Record<never, Field>,
131
- >(name: Name, spec: { request: Req; receipt?: Rec; endpoint?: string; declassify?: string }): CapabilityDecl<Req, Rec> {
182
+ >(name: Name, spec: {
183
+ request: Req;
184
+ receipt?: Rec;
185
+ endpoint?: string;
186
+ declassify?: string;
187
+ /**
188
+ * THE ACKNOWLEDGED WAIVER — open this capability's Receipts to any authorised author.
189
+ *
190
+ * Receipts are fenced by default (see {@link CAPABILITY_PROVIDER_RELATION}): closing an
191
+ * obligation is how an outside effect becomes chain fact, so by default it takes a
192
+ * device-key-signed author holding provider authority for this capability. A genuinely
193
+ * public capability may waive that, but it says WHY in the law — the same shape as
194
+ * `declassify`, replayable and auditable, never a silent default.
195
+ */
196
+ openReceipts?: string;
197
+ }): CapabilityDecl<Req, Rec> {
132
198
  if (!CAPABILITY_NAME_RE.test(name)) {
133
199
  throw new Error(
134
200
  `capability('${name}'): the name must match ${CAPABILITY_NAME_RE} (lowercase, dot-namespaced — e.g. 'llm.complete'); it doubles as the CapabilityBinding slot the executor must hold`,
@@ -175,17 +241,6 @@ export function capability<
175
241
  Object.entries(receipt).map(([k, f]) => [k, f.zod]),
176
242
  ) as { [K in keyof Rec]: Rec[K]["zod"] };
177
243
  const receiptEntries = Object.entries(receipt) as [string, Field][];
178
- const decodeReceipt = (resultRef: string): Record<string, unknown> => {
179
- const decoded = JSON.parse(resultRef) as unknown;
180
- if (decoded === null || typeof decoded !== "object" || Array.isArray(decoded)) {
181
- throw new Error("a typed capability receipt must encode a JSON object");
182
- }
183
- const values = decoded as Record<string, unknown>;
184
- for (const [k, f] of receiptEntries) {
185
- if (values[k] !== undefined) f.zod.parse(values[k]);
186
- }
187
- return values;
188
- };
189
244
 
190
245
  const quartet = impureCapability({
191
246
  aggregateId,
@@ -202,25 +257,17 @@ export function capability<
202
257
  Object.keys(spec.request).map((k) =>
203
258
  set(instance(taskAggregate, taskId), k as never, (p as Record<string, unknown>)[k] as never),
204
259
  ),
205
- // The receipt-field mechanism: typed values ride INSIDE resultRef as JSON;
206
- // decode + per-field zod-validate IN the plan (pure function of payload —
207
- // deterministic; a malformed executor result is a typed plan-halt refusal).
208
- // Absent keys are skipped (partial results are legal — read-decode
209
- // discipline), present keys must validate.
260
+ // The receipt half, symmetrical with the request half above: declared fields are merged
261
+ // onto the complete envelope as TYPED values and written straight through. The payload
262
+ // boundary has already validated them against the same zod the author declared, so the
263
+ // plan has nothing to decode. Absent keys are skipped — partial results stay legal.
210
264
  ...(receiptEntries.length > 0
211
265
  ? {
212
- completeResultSchema: z.string().refine((value) => {
213
- try { decodeReceipt(value); return true; } catch { return false; }
214
- }, "must encode a valid typed capability receipt JSON object"),
215
- completeResult: (taskId: string, resultRef: string): PlannedOp[] => {
216
- const decoded = decodeReceipt(resultRef);
217
- const ops: PlannedOp[] = [];
218
- for (const [k, f] of receiptEntries) {
219
- if (decoded[k] === undefined) continue;
220
- ops.push(set(instance(taskAggregate, taskId), k as never, f.zod.parse(decoded[k]) as never));
221
- }
222
- return ops;
223
- },
266
+ receiptSchema: receiptShape,
267
+ writeReceipt: (taskId: string, r: Record<string, unknown>): PlannedOp[] =>
268
+ receiptEntries
269
+ .filter(([k]) => r[k] !== undefined)
270
+ .map(([k]) => set(instance(taskAggregate, taskId), k as never, r[k] as never)),
224
271
  }
225
272
  : {}),
226
273
  capability: name,
@@ -229,8 +276,70 @@ export function capability<
229
276
  const camel = pascal[0]!.toLowerCase() + pascal.slice(1);
230
277
  const tasks = query(`${camel}TasksByStatus`).key("status").returns(taskAggregate);
231
278
 
279
+ // THE GATED GRANT — never the raw tuple writer.
280
+ //
281
+ // `relateSubject`/`unrelateSubject` are UNGATED framework primitives; governance learned this
282
+ // the hard way (re-exporting them registered an ungated `workspace:self#admin@…` writer — a
283
+ // self-grant-admin hole). A law that exported them alongside this fence would let anyone
284
+ // self-grant provider authority and close their own obligations. So provider authority is
285
+ // seated ONLY through these admin-gated wrappers, scoped to THIS capability's object.
286
+ const grantPayload = z.object({
287
+ subject: z.string().min(1),
288
+ grantedBy: z.string().min(1),
289
+ grantedAt: z.string().min(1),
290
+ });
291
+ const grantProvider = directive(`grant${pascal}Provider`)
292
+ .ensures(RelationTuple)
293
+ .payload(grantPayload)
294
+ .plan((p) =>
295
+ writeTuple({
296
+ object: capabilityProviderObject(name),
297
+ relation: CAPABILITY_PROVIDER_RELATION,
298
+ subject: p.subject,
299
+ grantedBy: p.grantedBy,
300
+ grantedAt: p.grantedAt,
301
+ }),
302
+ )
303
+ .requires("admin");
304
+ const revokeProvider = directive(`revoke${pascal}Provider`)
305
+ .ensures(RelationTuple)
306
+ .payload(grantPayload.extend({ revokedBy: z.string().min(1), revokedAt: z.string().min(1) }))
307
+ .plan((p) =>
308
+ removeTuple({
309
+ object: capabilityProviderObject(name),
310
+ relation: CAPABILITY_PROVIDER_RELATION,
311
+ subject: p.subject,
312
+ grantedBy: p.grantedBy,
313
+ grantedAt: p.grantedAt,
314
+ revokedBy: p.revokedBy,
315
+ revokedAt: p.revokedAt,
316
+ }),
317
+ )
318
+ .requires("admin");
319
+
320
+ // THE FENCE, APPLIED. Every Receipt verb is gated on provider authority for THIS capability
321
+ // unless the law carries an explicit `openReceipts` waiver. The gate is the shipped native
322
+ // relation gate, so it is re-derived on author, edge admission AND verify_chain — a receipt
323
+ // forged on the open session lane never folds anywhere.
324
+ const fenced = <D extends { requires: (rel: string, opts?: { object?: string }) => unknown }>(d: D): D =>
325
+ spec.openReceipts !== undefined
326
+ ? d
327
+ : (d.requires(CAPABILITY_PROVIDER_RELATION, {
328
+ object: capabilityProviderObject(name),
329
+ }) as D);
330
+
232
331
  return {
233
332
  ...quartet,
333
+ complete: fenced(quartet.complete),
334
+ fail: fenced(quartet.fail),
335
+ block: fenced(quartet.block),
336
+ deadLetter: fenced(quartet.deadLetter),
337
+ // The relation SCHEMA rides with the bundle so the gate can never be inert (a law without
338
+ // a relation schema does not enforce `.requires` at all).
339
+ relations: capabilityProviderRelations,
340
+ grantProvider,
341
+ revokeProvider,
342
+ ...(spec.openReceipts !== undefined ? { openReceipts: spec.openReceipts } : {}),
234
343
  aggregate: taskAggregate,
235
344
  instanceId: impureCapabilityInstanceId,
236
345
  name,
@@ -238,7 +347,7 @@ export function capability<
238
347
  ...(spec.declassify !== undefined ? { declassify: spec.declassify } : {}),
239
348
  requestSchema: z.object(requestShape),
240
349
  receiptSchema: z.object(receiptShape),
241
- encodeResult: (values) => JSON.stringify(z.object(receiptShape).parse(values)),
350
+ parseResult: (values) => z.object(receiptShape).parse(values),
242
351
  tasks,
243
352
  // The one-export expansion tag (capability_exports.ts): every exports walk
244
353
  // discovers this bundle's aggregate + quartet + scan query without re-exports.
@@ -102,12 +102,14 @@ export type ImpureCapabilityStatus = (typeof IMPURE_CAPABILITY_STATUSES)[number]
102
102
  * - requestedBy Lww — who obligated the task (provenance). Immutable-after-create.
103
103
  * - requestedAt Int — epoch-ms the obligation was authored. Immutable-after-create.
104
104
  * - status Lww enum — `requested` → the reactor flips to a terminal status.
105
- * - resultRef Lww string (OPTIONAL) — the content-addressed RESULT the reactor
106
- * records on completion: a blob ref / download url. The receipt-field
107
- * mechanism (capability.ts) TRANSPORTS typed values through this string
108
- * (JSON.stringify → JSON.parse into separate typed fields), so the ref
109
- * itself is honestly a STRING — NOT a `t.json()` leaf (which is now the
110
- * first-class `Value::Json`, reserved for genuinely-structured fields).
105
+ * - resultRef Lww string (OPTIONAL) — the content-addressed ARTIFACT the provider
106
+ * records on completion: a blob ref / download url, and nothing else. It
107
+ * is honestly a STRING because an artifact address IS a string. A
108
+ * capability with something structured to report DECLARES receipt fields;
109
+ * those travel as typed USDA values merged onto the complete payload
110
+ * (symmetrically with request params) and fold into their own typed
111
+ * fields. `resultRef` is required only for a capability that declares no
112
+ * receipt fields, so a Receipt can never report nothing.
111
113
  * Absent on a fresh request; absent on failure.
112
114
  * - failureReason Lww (OPTIONAL) — set only when the reactor flips to `failed`.
113
115
  * - completedAt Int (OPTIONAL) — epoch-ms the task reached its terminal state
@@ -193,14 +195,28 @@ const obligationEnvelopeSchema = z.object({
193
195
  deadlineAt: z.number().int(),
194
196
  });
195
197
 
196
- /** The receipt-COMPLETE payload: the request id + the content-addressed result + when. */
197
- const completeSchema = z.object({
198
+ /** The receipt-COMPLETE envelope every capability carries, whatever its result shape. */
199
+ const completeEnvelopeSchema = z.object({
198
200
  requestId: z.string(),
199
- // The produced artifact's content-addressed ref (blob ref / url) as a JSON string.
200
- resultRef: z.string(),
201
201
  completedAt: z.number().int(),
202
202
  });
203
203
 
204
+ /**
205
+ * The receipt-COMPLETE payload for a capability declaring NO typed receipt fields: the
206
+ * envelope plus a required content-addressed ref to the produced ARTIFACT (blob ref / url).
207
+ * It is an artifact address, never an envelope for typed values — a capability that has
208
+ * something to say declares receipt fields and says it in typed USDA.
209
+ */
210
+ const completeSchema = completeEnvelopeSchema.extend({
211
+ resultRef: z.string(),
212
+ });
213
+
214
+ /** The complete envelope for a capability whose result rides in DECLARED receipt fields:
215
+ * the artifact address becomes optional, because the typed fields carry the outcome. */
216
+ const completeArtifactOptionalSchema = completeEnvelopeSchema.extend({
217
+ resultRef: z.string().optional(),
218
+ });
219
+
204
220
  /** The receipt-FAIL payload: the request id + the failure reason + when. */
205
221
  const failSchema = z.object({
206
222
  requestId: z.string(),
@@ -272,13 +288,15 @@ export function obligationOps(
272
288
  */
273
289
  export function completeOps(
274
290
  aggregateId: string,
275
- p: z.infer<typeof completeSchema>,
291
+ p: z.infer<typeof completeEnvelopeSchema> & { resultRef?: string },
276
292
  resultOps: PlannedOp[] = [],
277
293
  ): PlannedOp[] {
278
294
  const taskInstance = envelopeInstance(aggregateId, p.requestId);
279
295
  return [
280
296
  set(taskInstance, "status", "completed"),
281
- set(taskInstance, "resultRef", p.resultRef),
297
+ // Present iff this capability produced an ARTIFACT. A typed-receipt capability carries
298
+ // its result in its declared fields instead, so it writes no artifact address at all.
299
+ ...(p.resultRef !== undefined ? [set(taskInstance, "resultRef", p.resultRef)] : []),
282
300
  set(taskInstance, "completedAt", p.completedAt),
283
301
  // The FRAMEWORK Obligation↔Receipt CLOSURE link — this Receipt closes the Obligation whose
284
302
  // id == requestId (the task instance the reactor mutates). Indexed by the read
@@ -349,16 +367,32 @@ export function deadLetterOps(
349
367
  * • `complete` — `.mutates` directive (the reactor's success Receipt),
350
368
  * • `fail` — `.mutates` directive (the reactor's failure Receipt),
351
369
  * plus the deterministic `instanceId` rule. The `obligation` payload is the framework
352
- * obligation-envelope MERGED with the tenant's `paramsSchema`; `complete`/`fail` are fixed.
370
+ * obligation-envelope MERGED with the tenant's `paramsSchema`; the `complete` payload is
371
+ * SYMMETRICALLY the complete-envelope merged with the tenant's `receiptSchema` (typed USDA
372
+ * values, never an encoded blob); `fail`/`block`/`deadLetter` are fixed.
353
373
  */
374
+ // Infer from the EXTENDED schema, exactly as ObligationPayload does — a conditional or an
375
+ // intersection around this turns a `.optional()` receipt field into a REQUIRED key of type
376
+ // `T | undefined` under exactOptionalPropertyTypes, so a genuinely optional receipt field
377
+ // could not be omitted. The receipt-free case is then the same shape with no extra keys,
378
+ // where `resultRef` is separately required (see `completeSchema`).
379
+ export type CompletePayload<ReceiptShape extends z.ZodRawShape> = z.infer<
380
+ ReturnType<typeof completeArtifactOptionalSchema.extend<ReceiptShape>>
381
+ > &
382
+ // A capability declaring NO receipt fields must still report SOMETHING, so its artifact
383
+ // address stays required. Intersected only in that branch — where there are no receipt
384
+ // fields whose optionality it could flatten.
385
+ (keyof ReceiptShape extends never ? { resultRef: string } : unknown);
386
+
354
387
  export interface ImpureCapability<
355
388
  Id extends string,
356
389
  ParamsShape extends z.ZodRawShape,
357
390
  F extends Record<string, Field>,
391
+ ReceiptShape extends z.ZodRawShape = Record<never, never>,
358
392
  > {
359
393
  readonly aggregate: AggregateHandle<Id, typeof IMPURE_CAPABILITY_ENVELOPE & F>;
360
394
  readonly obligation: RequirableDirective<ObligationPayload<ParamsShape>>;
361
- readonly complete: RequirableDirective<z.infer<typeof completeSchema>>;
395
+ readonly complete: RequirableDirective<CompletePayload<ReceiptShape>>;
362
396
  readonly fail: RequirableDirective<z.infer<typeof failSchema>>;
363
397
  readonly block: RequirableDirective<z.infer<typeof blockSchema>>;
364
398
  readonly deadLetter: RequirableDirective<z.infer<typeof deadLetterSchema>>;
@@ -429,6 +463,7 @@ export function impureCapability<
429
463
  const FailId extends string,
430
464
  ParamsShape extends z.ZodRawShape,
431
465
  F extends Record<string, Field>,
466
+ ReceiptShape extends z.ZodRawShape = Record<never, never>,
432
467
  >(spec: {
433
468
  aggregateId: Id;
434
469
  obligationDirectiveId: ObligationId;
@@ -446,29 +481,33 @@ export function impureCapability<
446
481
  params: z.infer<z.ZodObject<ParamsShape>>,
447
482
  ) => PlannedOp[];
448
483
  /**
449
- * OPTIONAL plan fragment writing the tenant's STRUCTURED RESULT fields onto the task
450
- * aggregate when the `complete` Receipt is authored (the lifecycle envelope + the
451
- * `closesObligation` link is auto). The receipt's `resultRef` carries the content-addressed
452
- * Evidence ref (the authoritative artifact); a tenant that ALSO wants the result in a
453
- * typed, queryable field (e.g. a voice-note's `transcription`) supplies it here. The
454
- * `resultRef` string is passed through so the tenant can derive the field value from the
455
- * result when it is the same data (e.g. `transcription = resultRef`). Wire-identical to
456
- * the Rust reactor's `result_fields` (`HandlerSuccess::with_result_field`).
484
+ * The task's RECEIPT Zod SHAPE — merged onto the complete envelope schema exactly as
485
+ * `paramsSchema` is merged onto the obligation envelope. A declared receipt travels as
486
+ * TYPED USDA VALUES on the wire, symmetrically with the request half.
487
+ *
488
+ * It previously rode as a JSON object stringified into `resultRef` and re-parsed in the
489
+ * plan. That made the receipt half of a capability the one place in the framework where a
490
+ * declared, typed shape degraded to an opaque string — unqueryable until decoded, invisible
491
+ * to the value algebra, and (in the kernel's owner-recovery lane) silently empty on a
492
+ * malformed parse. Receipts are law, so they are typed like law.
457
493
  */
458
- completeResult?: (taskInstanceId: string, resultRef: string) => PlannedOp[];
494
+ receiptSchema?: ReceiptShape;
459
495
  /**
460
- * OPTIONAL validation for the result reference before the plan runs. Typed capabilities use this
461
- * to make malformed encoded receipts impossible to admit at the payload boundary; receipt-free
462
- * capabilities retain the ordinary opaque-string result reference.
496
+ * Plan fragment writing the tenant's RECEIPT fields onto the task aggregate when the
497
+ * `complete` Receipt is authored (the lifecycle envelope + the `closesObligation` link is
498
+ * auto). The mirror of {@link writeParams}. Supplied iff `receiptSchema` is.
463
499
  */
464
- completeResultSchema?: z.ZodString;
500
+ writeReceipt?: (
501
+ taskInstanceId: string,
502
+ receipt: Record<string, unknown>,
503
+ ) => PlannedOp[];
465
504
  /**
466
505
  * OPTIONAL: the capability-credential name the provider host must hold a
467
506
  * law-state `CapabilityBinding` for (see {@link ImpureCapability.capability}).
468
507
  * Omit-when-absent — declaring nothing changes nothing on the wire.
469
508
  */
470
509
  capability?: string;
471
- }): ImpureCapability<Id, ParamsShape, F> {
510
+ }): ImpureCapability<Id, ParamsShape, F, ReceiptShape> {
472
511
  const taskAggregate = impureCapabilityAggregate(spec.aggregateId, spec.params);
473
512
  const typedRequestId = { requestId: idOf(taskAggregate) };
474
513
  const obligationPayloadBase = obligationEnvelopeSchema.extend(spec.paramsSchema);
@@ -476,9 +515,17 @@ export function impureCapability<
476
515
  // replaces; its extra aggregate identity lives in Zod metadata for lint/codegen.
477
516
  // Preserve the generic payload type while installing that richer runtime schema.
478
517
  const obligationPayload = obligationPayloadBase.extend(typedRequestId) as typeof obligationPayloadBase;
479
- const typedCompleteBase = completeSchema.extend(typedRequestId);
480
- const typedCompleteSchema = spec.completeResultSchema !== undefined
481
- ? typedCompleteBase.extend({ resultRef: spec.completeResultSchema })
518
+ // THE RECEIPT IS TYPED LAW, SYMMETRICAL WITH THE REQUEST. A capability that declares
519
+ // receipt fields carries them as typed values merged onto the complete envelope, exactly
520
+ // as the obligation merges its params. `resultRef` then keeps only its honest meaning —
521
+ // a content-addressed ref to a produced ARTIFACT — and is required solely when there are
522
+ // no declared receipt fields to carry the result, so a receipt can never say nothing.
523
+ const typedCompleteBase = (spec.receiptSchema !== undefined
524
+ ? completeArtifactOptionalSchema
525
+ : completeSchema
526
+ ).extend(typedRequestId);
527
+ const typedCompleteSchema = spec.receiptSchema !== undefined
528
+ ? typedCompleteBase.extend(spec.receiptSchema)
482
529
  : typedCompleteBase;
483
530
  const typedFailSchema = failSchema.extend(typedRequestId);
484
531
  const typedBlockSchema = blockSchema.extend(typedRequestId);
@@ -503,15 +550,23 @@ export function impureCapability<
503
550
  );
504
551
  });
505
552
 
506
- const complete = directive(spec.completeDirectiveId)
553
+ // The two branches above are distinct object schemas; the declared payload TYPE is the
554
+ // `CompletePayload<ReceiptShape>` conditional, so name it once here rather than leaking a
555
+ // union through the directive's public type.
556
+ const completePayload = typedCompleteSchema as unknown as z.ZodType<CompletePayload<ReceiptShape>>;
557
+
558
+ const complete: RequirableDirective<CompletePayload<ReceiptShape>> = directive(
559
+ spec.completeDirectiveId,
560
+ )
507
561
  .mutates(taskAggregate)
508
- .payload(typedCompleteSchema)
562
+ .payload(completePayload)
509
563
  .plan((p) =>
510
564
  completeOps(
511
565
  spec.aggregateId,
512
- p,
513
- // Tenant structured-result ops (e.g. `transcription`) if the task declares them.
514
- spec.completeResult?.(impureCapabilityInstanceId(p.requestId), p.resultRef) ?? [],
566
+ p as z.infer<typeof completeEnvelopeSchema> & { resultRef?: string },
567
+ // The tenant's TYPED receipt fields, read straight off the validated payload —
568
+ // no decode step, because there is no encoding to undo.
569
+ spec.writeReceipt?.(impureCapabilityInstanceId((p as { requestId: string }).requestId), p as Record<string, unknown>) ?? [],
515
570
  ),
516
571
  );
517
572
 
@@ -65,8 +65,28 @@ const recoveryOrderParams = {
65
65
  deviceRequest: t.string().merge(Lww),
66
66
  };
67
67
 
68
+ /**
69
+ * THE RECOVERY RECEIPT'S OWN DECLARED FIELD. The re-wrapped owned custody the recovery
70
+ * principal produces is the RESULT of this capability, so it is a typed receipt field.
71
+ *
72
+ * It previously rode as a JSON object stringified into `resultRef`, which the kernel dug back
73
+ * out with `serde_json::from_str(...).unwrap_or_default()` — so a malformed Receipt silently
74
+ * read as an EMPTY custody string on the accept leg rather than refusing. Custody handoff is
75
+ * the last thing that should degrade quietly.
76
+ */
77
+ const recoveryReceipt = {
78
+ ownedCustody: z.string().min(1),
79
+ };
80
+
81
+ /** Order fields ⊕ the receipt field, so the aggregate can hold what the Receipt writes. */
82
+ const recoveryOrderFields = {
83
+ ...recoveryOrderParams,
84
+ // Absent until the recovery principal's Receipt lands (the read-decode discipline).
85
+ ownedCustody: t.string().merge(Lww).optional(),
86
+ };
87
+
68
88
  /** Private framework Order -> Receipt journal. Generated business clients never expose this aggregate. */
69
- export const OwnedRecoveryOrder = impureCapabilityAggregate("NomosOwnedRecoveryOrder", recoveryOrderParams);
89
+ export const OwnedRecoveryOrder = impureCapabilityAggregate("NomosOwnedRecoveryOrder", recoveryOrderFields);
70
90
  const ownedRecoveryLifecycle = impureCapability({
71
91
  aggregateId: OwnedRecoveryOrder.id,
72
92
  obligationDirectiveId: OWNED_RECOVERY_REQUEST_DIRECTIVE,
@@ -74,7 +94,7 @@ const ownedRecoveryLifecycle = impureCapability({
74
94
  failDirectiveId: OWNED_RECOVERY_FAIL_DIRECTIVE,
75
95
  blockDirectiveId: OWNED_RECOVERY_BLOCK_DIRECTIVE,
76
96
  deadLetterDirectiveId: OWNED_RECOVERY_DEAD_LETTER_DIRECTIVE,
77
- params: recoveryOrderParams,
97
+ params: recoveryOrderFields,
78
98
  paramsSchema: {
79
99
  ownerPrincipal: principal,
80
100
  recoveryPrincipal: principal,
@@ -85,6 +105,10 @@ const ownedRecoveryLifecycle = impureCapability({
85
105
  set(instance(OwnedRecoveryOrder, id), "recoveryPrincipal", p.recoveryPrincipal),
86
106
  set(instance(OwnedRecoveryOrder, id), "deviceRequest", p.deviceRequest),
87
107
  ],
108
+ receiptSchema: recoveryReceipt,
109
+ writeReceipt: (id, r) => [
110
+ set(instance(OwnedRecoveryOrder, id), "ownedCustody", r.ownedCustody as string),
111
+ ],
88
112
  });
89
113
 
90
114
  export const ownedRecoveryOrdersByRecoveryPrincipal = query("nomosOwnedRecoveryOrdersByRecoveryPrincipal")
@@ -63,22 +63,30 @@ function optionalParamOps(
63
63
  return ops;
64
64
  }
65
65
 
66
- function syncResultOps(taskId: string, resultRef: string): PlannedOp[] {
66
+ /**
67
+ * The sync Receipt's own declared shape. These four were previously smuggled through
68
+ * `resultRef` as a JSON object and recovered with `typeof`/`Number.isInteger` guards — the
69
+ * receipt of the framework's OWN capability was untyped, so a provider that misspelled a key
70
+ * or sent a string count simply had that value dropped on the floor. They are ordinary typed
71
+ * receipt fields now: the payload boundary validates them and a wrong shape is refused.
72
+ */
73
+ const syncReceipt = {
74
+ resultHead: z.string().optional(),
75
+ resultCursor: z.string().optional(),
76
+ appliedIntentCount: z.number().int().optional(),
77
+ pushedIntentCount: z.number().int().optional(),
78
+ };
79
+
80
+ function syncResultOps(taskId: string, r: Record<string, unknown>): PlannedOp[] {
67
81
  const sync = instance(WorkspaceSyncOperation, taskId);
68
82
  const ops: PlannedOp[] = [];
69
- const result = JSON.parse(resultRef) as {
70
- head?: unknown;
71
- cursor?: unknown;
72
- appliedIntentCount?: unknown;
73
- pushedIntentCount?: unknown;
74
- };
75
- if (typeof result.head === "string") ops.push(set(sync, "resultHead", result.head));
76
- if (typeof result.cursor === "string") ops.push(set(sync, "resultCursor", result.cursor));
77
- if (Number.isInteger(result.appliedIntentCount)) {
78
- ops.push(set(sync, "appliedIntentCount", result.appliedIntentCount as number));
83
+ if (r.resultHead !== undefined) ops.push(set(sync, "resultHead", r.resultHead as string));
84
+ if (r.resultCursor !== undefined) ops.push(set(sync, "resultCursor", r.resultCursor as string));
85
+ if (r.appliedIntentCount !== undefined) {
86
+ ops.push(set(sync, "appliedIntentCount", r.appliedIntentCount as number));
79
87
  }
80
- if (Number.isInteger(result.pushedIntentCount)) {
81
- ops.push(set(sync, "pushedIntentCount", result.pushedIntentCount as number));
88
+ if (r.pushedIntentCount !== undefined) {
89
+ ops.push(set(sync, "pushedIntentCount", r.pushedIntentCount as number));
82
90
  }
83
91
  return ops;
84
92
  }
@@ -110,7 +118,8 @@ const workspaceSyncTask = impureCapability({
110
118
  ...optionalParamOps(sync, p),
111
119
  ];
112
120
  },
113
- completeResult: syncResultOps,
121
+ receiptSchema: syncReceipt,
122
+ writeReceipt: syncResultOps,
114
123
  });
115
124
 
116
125
  export const requestWorkspaceSync = workspaceSyncTask.obligation.requires("requestWorkspaceSync");