@passioncode-ai/fabric-agent-adapter 0.51.2 → 0.53.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +8 -4
  3. package/SECURITY.md +18 -3
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +4 -1
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +8 -4
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +85 -13
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +22 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +44 -6
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +48 -9
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +6 -4
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +1 -1
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +7 -2
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +30 -15
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +33 -3
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +146 -7
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +5 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +17 -9
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate-rules.mjs +606 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +48 -525
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +43 -6
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +14 -4
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +48 -15
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +4 -3
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +39 -16
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +34 -3
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +149 -45
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +8 -1
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -11
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +86 -642
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate_rules.py +766 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +70 -12
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +59 -8
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +56 -16
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +4 -2
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +314 -25
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +13 -1
  42. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  43. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +4 -1
  44. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +7 -0
  45. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +75 -36
  46. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +28 -14
  47. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +42 -12
  48. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +11 -0
  49. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +12 -3
  50. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +12 -2
  51. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +360 -16
@@ -26,6 +26,9 @@ const TERMINAL = new Set(['completed', 'failed_known', 'cancelled', 'expired']);
26
26
  // participant reply (DEC-0098, DEC-0102; not terminal, the requester may still cancel it, and nobody claims it).
27
27
  const NOT_OURS = new Set(['queued', 'claimed', 'answered']);
28
28
 
29
+ // The answer may have been stored: send it again with the same key (BoardError).
30
+ const LOST = ['unreachable', 'session-ended'];
31
+
29
32
  /** A lost call is sent again this long before the lease it keeps would lapse (FAA-77, finding 38). */
30
33
  export const LEASE_MARGIN_SECONDS = 10;
31
34
 
@@ -87,6 +90,7 @@ export class Responder {
87
90
  /** One fenced call at a time, and the entry edits beside them (FAA-77, finding 36). */
88
91
  this.fenceQueue = Promise.resolve();
89
92
  this.toResume = []; // transferred requests waiting until nothing runs (finding 39)
93
+ this.suspect = new Set(); // capabilities a fenced call was refused on: com.status says whether they moved (FAA-155)
90
94
  }
91
95
 
92
96
  /** Runs fn after every fenced call queued before it (finding 36): an await inside a call never lets another in. */
@@ -167,13 +171,14 @@ export class Responder {
167
171
  const args = { idempotency: this.idem(this.key('enroll')), capability };
168
172
  let receipt;
169
173
  try {
170
- receipt = this.receipt('com.enroll', await this.call('com.enroll', args));
174
+ receipt = this.receipt('com.enroll', await this.ask(capability, 'com.enroll', args));
171
175
  } catch (e) {
172
176
  if (e.code === 'held') {
173
177
  this.pause(capability);
174
178
  return;
175
179
  }
176
180
  if (e.code === 'fenced') {
181
+ if (await this.gone(capability)) return;
177
182
  this.slots[capability] = { state: 'contested', renewedAt: this.now() };
178
183
  this.emit('board.slot.contested', 'warn', `Another responder holds the ${capability} slot on this Project; this service waits for it to lapse and never takes it over by itself.`, { notify: true, episode: capability, capability });
179
184
  return;
@@ -181,6 +186,7 @@ export class Responder {
181
186
  throw e;
182
187
  }
183
188
  this.slots[capability] = { state: 'held', generation: receipt.slot.generation, renewedAt: this.now() };
189
+ this.heldAt(capability, receipt.slot.generation);
184
190
  delete this.said['board.slot.contested'];
185
191
  this.lifted(capability);
186
192
  }
@@ -205,6 +211,118 @@ export class Responder {
205
211
  this.emit('board.slot.released', 'info', `The operator's hold on ${capability} was lifted; the service takes new work again.`, { capability });
206
212
  }
207
213
 
214
+ // -- a slot moved to another session (FAA-155, DEC-0140 PR-2) --
215
+ /** The generation this responder holds the slot at, kept beside its checkpoint; a failed write is retried at the next receipt. */
216
+ heldAt(capability, generation) {
217
+ this.note(capability, { project: this.project, generation });
218
+ }
219
+
220
+ /** The slot record, best effort: one that cannot be written now is written at the next receipt. */
221
+ note(capability, record) {
222
+ try {
223
+ this.checkpoint.slot(capability, record);
224
+ } catch {
225
+ // the request steps fail closed on their own
226
+ }
227
+ }
228
+
229
+ /**
230
+ * An enroll or replace is recorded with its key before it is sent, so a lost answer is never read as a move; a refusal
231
+ * drops the record, a receipt replaces it (heldAt), a record that cannot be written is skipped.
232
+ */
233
+ async ask(capability, op, args) {
234
+ let held = this.checkpoint.slots[capability] ?? {};
235
+ if (held.project !== this.project) return this.call(op, args); // no record: nothing a lost answer could be mistaken for
236
+ const slot = this.slots[capability] ?? {};
237
+ if (op === 'com.enroll' && slot.state === 'held' && slot.generation === held.generation
238
+ && this.now() - (slot.renewedAt ?? 0) < LEASE_SECONDS - LEASE_MARGIN_SECONDS && !('asking' in held)) {
239
+ return this.call(op, args); // a live holder's renew cannot change its generation: nothing to record
240
+ }
241
+ if (isObj(held.asking)) {
242
+ // An earlier lost ask is settled first; it may show the slot moved.
243
+ if (await this.gone(capability)) throw new BoardError('fenced', `The ${capability} slot moved to another session.`, false, op);
244
+ held = this.checkpoint.slots[capability] ?? {};
245
+ }
246
+ try {
247
+ this.checkpoint.slot(capability, { ...held, asking: { op, args } });
248
+ } catch {
249
+ return this.call(op, args);
250
+ }
251
+ try {
252
+ return await this.call(op, args);
253
+ } catch (e) {
254
+ if (!LOST.includes(e.code)) this.note(capability, held);
255
+ throw e;
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Asks a lost enroll or replace again under its key. `repeated: true`: it landed, the generation is ours. Else it ran
261
+ * only now: if the slot had moved past the record (`before`), the record stays and the move is read (the slot just
262
+ * taken lapses unused). A refusal or an odd receipt drops the record; a lost answer keeps it.
263
+ */
264
+ async replay(capability, held, before) {
265
+ const { asking, ...rest } = held;
266
+ let record = rest;
267
+ if (isObj(asking) && ['com.enroll', 'com.responder_replace'].includes(asking.op)) {
268
+ try {
269
+ const answer = this.receipt(asking.op, await this.call(asking.op, asking.args));
270
+ const generation = answer.slot?.generation;
271
+ const moved = isInt(before, 1) && isInt(rest.generation, 1) && before > rest.generation;
272
+ if (isInt(generation, 1) && (answer.repeated === true || !moved)) record = { project: this.project, generation };
273
+ } catch (e) {
274
+ if (LOST.includes(e.code)) throw e;
275
+ if (e.code === 'idempotency_window_expired') record = {}; // a retired epoch: unknown, so a first start, no move
276
+ }
277
+ }
278
+ this.note(capability, Object.keys(record).length ? record : null);
279
+ return record;
280
+ }
281
+
282
+ /**
283
+ * True when com.status shows the slot's generation past the one recorded held: the operator or another session moved
284
+ * it. The slot is left `moved`. No record, another Project's, an odd value or no later generation: false.
285
+ */
286
+ async gone(capability, status = null) {
287
+ let held = this.checkpoint.slots[capability] ?? {};
288
+ if (held.project !== this.project || (!isInt(held.generation, 1) && !('asking' in held))) return false;
289
+ let now = ((status ?? await this.status()).responders ?? []).find((r) => isObj(r) && r.capability === capability)?.generation;
290
+ if ('asking' in held) held = await this.replay(capability, held, now); // its own lost ask first
291
+ if (isInt(held.moved, 1) /* a recorded move stays until takeBack */ && !(isInt(now, 1) && now > (held.generation ?? now))) now = held.moved;
292
+ if (!isInt(now, 1) || !isInt(held.generation, 1) || now <= held.generation) return false;
293
+ if (this.current?.entry.capability === capability) {
294
+ this.current.ctx.stopped = true;
295
+ this.current = null;
296
+ }
297
+ for (const entry of Object.values(this.checkpoint.entries).filter((e) => e.capability === capability).map((e) => ({ ...e }))) {
298
+ if (entry.step === 'claimed') this.checkpoint.drop(entry.request); // the move returned the claim to the queue
299
+ else if (!['unknown', 'lapsed'].includes(entry.step)) {
300
+ // Begun: outcome_unknown, which the slot's holder reconciles. Accepted: held on the board, continued only by a
301
+ // transfer if the slot is this service's again; never a com.responder_replace on its own.
302
+ entry.step = entry.effect ? 'unknown' : 'lapsed';
303
+ this.checkpoint.put(entry);
304
+ }
305
+ }
306
+ this.toResume = this.toResume.filter((r) => this.checkpoint.entries[r]?.capability !== capability);
307
+ this.slots[capability] = { state: 'moved', generation: now, renewedAt: this.now() };
308
+ if (!('moved' in held)) { // once, even across restarts
309
+ this.emit('board.slot.moved', 'warn', `The ${capability} slot on this Project was moved to another session. This service stopped answering it there, left its accepted work held (work already begun is the new holder's to settle) and does not take the slot back by itself. Its take-back action brings it back once the other session lets it go; without one, ask its maintainer.`, { notify: true, episode: capability, capability, generation: now, held: held.generation });
310
+ this.note(capability, { ...held, moved: now }); // said again at the next start if it cannot be written
311
+ }
312
+ return true;
313
+ }
314
+
315
+ /**
316
+ * The operator's act, never the kit's: forget that a capability's slot moved away. In the same board session the next
317
+ * tick enrolls once the slot is free; a reconnect with held work takes it back at once by a transfer (FAA-155).
318
+ */
319
+ takeBack(capability) {
320
+ if (!this.capabilities.includes(capability)) throw new BoardError('invalid_arguments', `This responder does not answer ${capability}.`);
321
+ this.checkpoint.slot(capability, null);
322
+ if (this.slots[capability]?.state === 'moved') this.slots[capability] = { state: 'contested', renewedAt: 0 };
323
+ delete this.said['board.slot.moved'];
324
+ }
325
+
208
326
  // -- restart: reconcile and transfer --
209
327
  async readState(entry) {
210
328
  const message = await this.call('com.get', { message: entry.message });
@@ -237,13 +355,18 @@ export class Responder {
237
355
  const generations = Object.fromEntries(responders.map((r) => [r.capability, r.generation ?? 0]));
238
356
  const onHold = new Set(responders.filter((r) => r.held === true).map((r) => r.capability)); // DEC-0079
239
357
  for (const capability of capabilities) {
358
+ const was = this.checkpoint.slots[capability]?.generation;
359
+ if (await this.gone(capability, status)) continue; // moved away: never replaced at recovery (FAA-155)
240
360
  if (onHold.has(capability)) {
241
361
  for (const entry of transfer[capability] ?? []) {
242
362
  entry.step = 'lapsed'; // taken back by a transfer once the hold is lifted
243
363
  this.checkpoint.put(entry);
244
364
  }
245
365
  this.pause(capability);
246
- } else if (transfer[capability]?.length) await this.replace(capability, transfer[capability], generations[capability] ?? 0);
366
+ } else if (transfer[capability]?.length) { // at the generation a replay just recorded, else com.status's
367
+ const mine = this.checkpoint.slots[capability]?.generation;
368
+ await this.replace(capability, transfer[capability], isInt(mine, 1) && mine !== was ? mine : generations[capability] ?? 0);
369
+ }
247
370
  else await this.enroll(capability);
248
371
  }
249
372
  for (const entry of reconcile) {
@@ -260,8 +383,10 @@ export class Responder {
260
383
  if (entries.length) args.transfer = { requests: entries.map((e) => e.request).slice(0, 64), checkpoint: { summary: 'Resumed by the same service after a restart; no effect had begun.' } };
261
384
  let receipt;
262
385
  try {
263
- receipt = this.receipt('com.responder_replace', await this.call('com.responder_replace', args));
386
+ receipt = this.receipt('com.responder_replace', await this.ask(capability, 'com.responder_replace', args));
264
387
  } catch (e) {
388
+ // An earlier lost ask showed the slot moved: said once as board.slot.moved, no outage.
389
+ if (e.code === 'fenced' && this.slots[capability]?.state === 'moved') return;
265
390
  if (e.code !== 'held') throw e;
266
391
  for (const entry of entries) {
267
392
  entry.step = 'lapsed'; // nothing moved: taken back by a transfer once the hold is lifted
@@ -271,6 +396,7 @@ export class Responder {
271
396
  return;
272
397
  }
273
398
  this.slots[capability] = { state: 'held', generation: receipt.slot.generation, renewedAt: this.now() };
399
+ this.heldAt(capability, receipt.slot.generation);
274
400
  this.lifted(capability);
275
401
  const moved = new Map((receipt.transferred ?? []).map((item) => [item.request, item]));
276
402
  for (const entry of entries) {
@@ -418,6 +544,7 @@ export class Responder {
418
544
 
419
545
  async fault(e) {
420
546
  const code = e.code ?? 'unreachable';
547
+ const connecting = this.state === 'connecting' && !Object.values(this.slots).some((s) => s?.state === 'held');
421
548
  let wait = this.backoff.next();
422
549
  const left = this.leaseLeft() - LEASE_MARGIN_SECONDS;
423
550
  const keep = code === 'unreachable' && left > 0; // finding 38: the lease holds, so the session and the work stay
@@ -437,7 +564,8 @@ export class Responder {
437
564
  this.emit('board.blocked', 'error', `The responder stopped answering requests: ${e.message}`, { notify: true, episode: code, reason: code });
438
565
  } else {
439
566
  this.state = 'down';
440
- if (['session-ended', 'unreachable', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol'].includes(code) && !keep) await this.dropSession();
567
+ // A refusal in recovery that left no slot held reconnects (seam 6); one that left some held serves them (seam 7).
568
+ if ((connecting || ['session-ended', 'unreachable', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol'].includes(code)) && !keep) await this.dropSession();
441
569
  this.emit('board.down', 'warn', 'Fabric\'s board cannot be reached; the responder tries again with a growing pause.', { episode: 'down', reason: code });
442
570
  }
443
571
  return wait;
@@ -470,7 +598,12 @@ export class Responder {
470
598
  else await this.renew(work);
471
599
  }
472
600
  const heldByWork = work && this.current ? work.entry.capability : null;
601
+ for (const capability of [...this.suspect]) { // kept until com.status answered, so it is asked before any enroll
602
+ await this.gone(capability);
603
+ this.suspect.delete(capability);
604
+ }
473
605
  for (const [capability, slot] of Object.entries(this.slots)) {
606
+ if (slot.state === 'moved') continue; // never enrolled again on its own, even when the new holder's lease lapses (FAA-155)
474
607
  if (slot.state === 'paused') {
475
608
  // still held: a longer pause; lifted: the slot is ours again
476
609
  if (capability !== heldByWork && this.now() >= (slot.retryAt ?? 0)) await this.enroll(capability);
@@ -521,6 +654,7 @@ export class Responder {
521
654
  answer = this.receipt('com.claim', await this.call('com.claim', { idempotency: this.idem(this.claimKeys[capability]), capability }));
522
655
  } catch (e) {
523
656
  if (e.code === 'lease_expired') {
657
+ this.slots[capability].renewedAt = -Infinity; // lapsed: this enroll may move the generation, so it is recorded
524
658
  await this.enroll(capability);
525
659
  continue;
526
660
  }
@@ -530,7 +664,7 @@ export class Responder {
530
664
  continue;
531
665
  }
532
666
  if (e.code === 'fenced') {
533
- this.slots[capability] = { state: 'contested', renewedAt: this.now() };
667
+ if (!(await this.gone(capability))) this.slots[capability] = { state: 'contested', renewedAt: this.now() };
534
668
  continue;
535
669
  }
536
670
  if (e.code === 'held') {
@@ -648,6 +782,7 @@ export class Responder {
648
782
  continue;
649
783
  }
650
784
  if (STOP_CODES.has(e.code) || ['revision_conflict', 'invalid_transition', 'digest_conflict'].includes(e.code)) {
785
+ if (e.code === 'fenced') this.suspect.add(entry.capability); // the next tick asks com.status whether the slot moved
651
786
  this.lose(entry, e.code);
652
787
  if (e.code === 'authority_changed') throw e;
653
788
  return null;
@@ -663,10 +798,13 @@ export class Responder {
663
798
  entry.step = 'unknown';
664
799
  this.checkpoint.put(entry);
665
800
  this.emit('board.reconcile.needed', 'warn', 'The attempt was lost after its effect began; the outcome stays unknown until this service observes what happened.', { notify: true, episode: entry.request, request: entry.request, capability: entry.capability, reason: code });
666
- } else if (code === 'lease_expired' && !['claimed', 'accepting'].includes(entry.step)) {
801
+ } else if (!['claimed', 'accepting'].includes(entry.step) && (code === 'lease_expired' || (code === 'fenced' && entry.capability in this.checkpoint.slots))) {
802
+ // Accepted work whose lease lapsed, or fenced off a slot this responder held (moved, FAA-155): the board holds it
803
+ // (C4). It continues only by a checkpoint transfer to this responder while it holds the slot.
667
804
  entry.step = 'lapsed';
668
805
  this.checkpoint.put(entry);
669
- this.emit('board.attempt.lost', 'warn', 'This service\'s lease on an accepted request lapsed; it stopped working on it and takes it back by a transfer.', { request: entry.request, capability: entry.capability, reason: code });
806
+ this.emit('board.attempt.lost', 'warn', code === 'lease_expired' ? 'This service\'s lease on an accepted request lapsed; it stopped working on it and takes it back by a transfer.'
807
+ : 'The board fenced an accepted request away from this service; it stopped working on it, and nothing had been done outside it.', { request: entry.request, capability: entry.capability, reason: code });
670
808
  } else {
671
809
  this.checkpoint.drop(entry.request);
672
810
  this.emit('board.attempt.lost', 'warn', `The board took a request back from this service (${code}); it stopped working on it, and nothing had been done outside the service.`, { request: entry.request, capability: entry.capability, reason: code });
@@ -854,6 +992,7 @@ export class Responder {
854
992
  if (this.session) {
855
993
  await this.session.close();
856
994
  this.session = null;
995
+ this.checkpoint.leave(this.project); // the slots were released: no record of them can read as a move
857
996
  }
858
997
  this.state = 'stopped';
859
998
  }
@@ -3,6 +3,11 @@
3
3
  // Part of the Node service kit (layer L3, after fabric-descriptor); the twin of fabric_context.py, answer for answer
4
4
  // (test/fixtures/kit-context-cases.json). Node built-ins only. The facade fabric-service.mjs does not re-export it.
5
5
  // A refusal exits 78 (EX_CONFIG) with nothing created; a second copy for the same context exits 75 as holdSingleInstance does.
6
+ // An organization's instance whose context the record does not pin (`organizations`, DEC-0127) still starts: the helper
7
+ // checks the placement and its share grant, not the pin (decision D5 of 0.52.0). Its tokens are refused instead:
8
+ // hubKeyCertified reads a context's hub keys only while the record pins that organization (DL-14a, DEC-0128), so the
9
+ // service passes `estateContext: context` to its delegation gate. A refusal at start would stop every organization
10
+ // instance on today's records, which pin none until every reader is pinned at or after DEC-0127.
6
11
  import crypto from 'node:crypto';
7
12
  import fs from 'node:fs';
8
13
  import path from 'node:path';
@@ -263,10 +263,11 @@ const readableClock = (now) => typeof now === 'number' && Number.isFinite(now) &
263
263
  * `kid` and the published key must be certified by that revision. `estate` is a revision the caller verified
264
264
  * (fabric-estate.mjs verifyEstateRecord) and its machine applied, or a function returning it; only an absent `estate`
265
265
  * skips the rule (null is an unreadable source). `estateApplied` is the highest revision the machine applied,
266
- * `estateRevocations` the revocation delta's entries since (an array or a function). A source that throws is
267
- * unreadable, never a pass. `[kind, detail]` or null.
266
+ * `estateRevocations` the revocation delta's entries since (an array or a function), `estateContext` the context this
267
+ * callee serves (DEC-0128: an organization's `org:<orgId>` reads that context's hub keys while the record pins it;
268
+ * absent or `personal`, the personal ones). A source that throws is unreadable, never a pass. `[kind, detail]` or null.
268
269
  */
269
- function estateProblem({ estate, estateApplied, estateRevocations }, kid, x, now) {
270
+ function estateProblem({ estate, estateApplied, estateRevocations, estateContext }, kid, x, now) {
270
271
  if (estate === undefined) return null;
271
272
  let record;
272
273
  let extra;
@@ -276,7 +277,10 @@ function estateProblem({ estate, estateApplied, estateRevocations }, kid, x, now
276
277
  } catch {
277
278
  return ['issuer', 'estate-unreadable'];
278
279
  }
279
- const verdict = hubKeyCertified(record, kid, x, now, estateApplied, extra === undefined ? undefined : { extraRevocations: extra });
280
+ const verdict = hubKeyCertified(record, kid, x, now, estateApplied, {
281
+ ...(extra === undefined ? {} : { extraRevocations: extra }),
282
+ ...(estateContext === undefined ? {} : { context: estateContext }),
283
+ });
280
284
  // The clock was refused before row 1, so a verdict here is the token's or the trust source's.
281
285
  return verdict.certified ? null : [verdict.fault === 'token' ? 'token' : 'issuer', verdict.detail];
282
286
  }
@@ -284,16 +288,18 @@ function estateProblem({ estate, estateApplied, estateRevocations }, kid, x, now
284
288
  /**
285
289
  * DL-13, in its fixed order: nothing about a token is believed before its signature is. `now` is seconds since
286
290
  * the epoch; a clock that is not one is refused `unknown-state` `no-clock` before row 1. Answers {ok: true, claims,
287
- * principal, caller, chain, kid} or {ok: false, code, detail}.
291
+ * principal, caller, chain, kid} or {ok: false, code, detail}. `estateContext` (DL-14a, DEC-0128): the context this
292
+ * callee serves; an organization's `org:<orgId>` verifies the token under that context's hub key, and `issuer` is then
293
+ * that organization pin's context issuer (`organizations[].iss`); absent or `personal`, the personal hub key.
288
294
  */
289
- export function verifyDelegation(token, { audience, jwks, now, capability, effect, issuer, readOnly, estate, estateApplied, estateRevocations } = {}) {
295
+ export function verifyDelegation(token, { audience, jwks, now, capability, effect, issuer, readOnly, estate, estateApplied, estateRevocations, estateContext } = {}) {
290
296
  // Before row 1: every time row compares against the clock, and a comparison with NaN is always false.
291
297
  if (!readableClock(now)) return refuse('unknown-state', 'no-clock');
292
298
  if (!isNonEmptyString(issuer)) return refuse('issuer-unavailable', 'no-trusted-issuer');
293
299
  if (!hasKeys(jwks)) return refuse('issuer-unavailable', 'no-published-keys');
294
300
  const checked = authentic(token, jwks, DELEGATION_TYP);
295
301
  if ('problem' in checked) return refuse(checked.issuer ? 'issuer-unavailable' : 'unauthenticated', checked.problem);
296
- const uncertified = estateProblem({ estate, estateApplied, estateRevocations }, checked.kid, checked.x, now);
302
+ const uncertified = estateProblem({ estate, estateApplied, estateRevocations, estateContext }, checked.kid, checked.x, now);
297
303
  if (uncertified) return refuse(uncertified[0] === 'issuer' ? 'issuer-unavailable' : 'unauthenticated', uncertified[1]);
298
304
  const shaped = delegationShape(checked.compact.payload);
299
305
  if ('problem' in shaped) return refuse('unauthenticated', shaped.problem);
@@ -465,8 +471,10 @@ export const TIERS = Object.freeze(['T0', 'T1', 'T2']);
465
471
  * service's own local floor authenticated, T1 an enrolled machine of the same owner, T2 everything else (and any call
466
472
  * relayed from off the machine). A T0 or T1 caller is never required to present a token: with none, the call passes
467
473
  * with no delegation. A present token is verified at every tier. `issuer` (auth.delegation.issuer) is required.
474
+ * `estateContext` is the context the service's instance runs in (DL-14a, DEC-0128: an organization's instance passes its
475
+ * `org:<orgId>`, and `issuer` its pin's context issuer).
468
476
  */
469
- export function delegationGate({ audience, jwks, effects, issuer, readOnly = false, clock = () => Date.now() / 1000, estate, estateApplied, estateRevocations }) {
477
+ export function delegationGate({ audience, jwks, effects, issuer, readOnly = false, clock = () => Date.now() / 1000, estate, estateApplied, estateRevocations, estateContext }) {
470
478
  if (!isNonEmptyString(issuer)) throw new Error('the gate needs the issuer it trusts (auth.delegation.issuer)');
471
479
  return async (tool, headers, tier = 'T2') => {
472
480
  const effect = effects?.[tool];
@@ -477,7 +485,7 @@ export function delegationGate({ audience, jwks, effects, issuer, readOnly = fal
477
485
  return refuse('unauthenticated', 'no-token');
478
486
  }
479
487
  const keys = jwks instanceof JwksCache ? await jwks.get(kidOf(carried.token)) : jwks;
480
- const verdict = verifyDelegation(carried.token, { audience, jwks: keys, now: clock(), capability: tool, effect, readOnly, issuer, estate, estateApplied, estateRevocations });
488
+ const verdict = verifyDelegation(carried.token, { audience, jwks: keys, now: clock(), capability: tool, effect, readOnly, issuer, estate, estateApplied, estateRevocations, estateContext });
481
489
  if (verdict.ok) verdict.tier = TIERS.includes(tier) ? tier : 'T2';
482
490
  return verdict;
483
491
  };