@passioncode-ai/fabric-agent-adapter 0.35.3 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +45 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +37 -1
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +13 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +129 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +67 -4
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +252 -5
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +5 -4
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +4 -3
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +109 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +194 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +82 -4
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +356 -9
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +10 -4
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -4
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +173 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/share-registration.schema.json +218 -0
  25. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  26. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +13 -4
  27. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  28. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +3 -0
  29. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +20 -5
@@ -173,27 +173,39 @@ def unknown_job_result(job_id: str, traceparent: Optional[str] = None) -> Dict[s
173
173
 
174
174
  # --- C3.2 the result envelope ------------------------------------------------------
175
175
 
176
- def _usage(usage: Any) -> Dict[str, Any]:
176
+ def _usage(usage: Any, nulls: bool = False) -> Dict[str, Any]:
177
+ """A usage object as fabric-interop/0.1 reads it, or with `nulls` as 0.2 does: a number nobody measured is null and
178
+ `partial` a boolean (DEC-0087)."""
177
179
  if not isinstance(usage, dict):
178
180
  raise InteropError("usage must be an object.")
179
181
  for key in ("inputTokens", "outputTokens", "wallMs"):
180
182
  if key not in usage:
181
183
  raise InteropError("usage.%s is required." % key)
182
184
  for key, value in usage.items():
183
- if key not in ("inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "costUsd", "wallMs"):
185
+ if nulls and key == "partial":
186
+ if not isinstance(value, bool):
187
+ raise InteropError("usage.partial must be a boolean.")
188
+ continue
189
+ if key not in USAGE_NUMBERS:
184
190
  raise InteropError("usage.%s is not a usage field." % key)
191
+ if nulls and value is None:
192
+ continue
185
193
  numeric = isinstance(value, (int, float)) and not isinstance(value, bool)
186
194
  if not numeric or value < 0 or (key != "costUsd" and not isinstance(value, int)):
187
195
  raise InteropError("usage.%s must be a non-negative %s." % (key, "number" if key == "costUsd" else "integer"))
196
+ if nulls and usage_findings(usage, ""):
197
+ raise InteropError("usage.partial is false while a number is null: an unknown number makes the usage partial (FAC-SEM-119).")
188
198
  return dict(usage)
189
199
 
190
200
 
191
201
  def result_envelope(*, outcome: str, done: List[Dict[str, Any]], proof: List[Dict[str, Any]], scope: Dict[str, Any],
192
202
  not_verified: List[Dict[str, Any]], output: Any, usage: Dict[str, Any], producer: Dict[str, Any],
193
203
  artifacts: Optional[List[Dict[str, Any]]] = None, traceparent: Optional[str] = None,
194
- result_id: Optional[str] = None, created_at: Optional[str] = None) -> Dict[str, Any]:
204
+ result_id: Optional[str] = None, created_at: Optional[str] = None,
205
+ request_meta: Any = None) -> Dict[str, Any]:
195
206
  """The full result envelope (contract result.schema.json, DEC-0011, DEC-0017): the same shape a
196
- synchronous call returns, with output, usage and — when the work was traced — its trace."""
207
+ synchronous call returns, with output, usage and, when the work was traced, its trace. `usage` may hold null for
208
+ a number nobody measured; it is written as `usage_toward` the caller whose request `_meta` is `request_meta`."""
197
209
  if outcome not in OUTCOMES:
198
210
  raise InteropError("outcome must be one of %s." % ", ".join(OUTCOMES))
199
211
  for label, value in (("done", done), ("proof", proof), ("notVerified", not_verified), ("artifacts", artifacts or [])):
@@ -207,13 +219,284 @@ def result_envelope(*, outcome: str, done: List[Dict[str, Any]], proof: List[Dic
207
219
  "id": result_id or "urn:fabric:result:" + secrets.token_hex(12), "contractVersion": CONTRACT_VERSION,
208
220
  "outcome": outcome, "done": list(done), "proof": list(proof), "scope": dict(scope),
209
221
  "notVerified": list(not_verified), "artifacts": list(artifacts or []), "createdAt": created_at or _fs.now_iso(),
210
- "producer": dict(producer), "output": output, "usage": _usage(usage),
222
+ "producer": dict(producer), "output": output, "usage": usage_toward(usage, request_meta),
211
223
  }
212
224
  if parse_traceparent(traceparent):
213
225
  envelope["trace"] = {"traceparent": traceparent}
214
226
  return envelope
215
227
 
216
228
 
229
+ # --- C3.2 limits and uncertain effects (DEC-0087) ------------------------------------------
230
+ # #region job-limits-kit, docs: plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md#limits-and-uncertain-effects
231
+ # The contract's src/job-limits.ts, answer for answer (test/fixtures/contract/job-limit-vectors.json). Every function
232
+ # here answers and never raises on malformed input.
233
+
234
+ QUOTA_EXHAUSTED = "quota-exhausted"
235
+ OUTCOME_UNKNOWN = "outcome-unknown"
236
+ LIMIT_REACHED = "limit-reached"
237
+ INTEROP_NULL_USAGE = "fabric-interop/0.2"
238
+ #: The revisions this kit reads, named in a request's `_meta.protocols` (DEC-0067, DEC-0087).
239
+ PROTOCOLS_READ = ("fabric-interop/0.1", INTEROP_NULL_USAGE)
240
+ USAGE_NUMBERS = ("inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "costUsd", "wallMs")
241
+ _UTC_INSTANT = re.compile(r"^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-5][0-9])(?:\.([0-9]{1,3}))?Z\Z")
242
+ _PAUSE = re.compile(r"^quota-exhausted(?: until ([^ \t\n\r]+?))?(?:: ([ \t\n\r]*[^ \t\n\r][\s\S]*))?\Z")
243
+ _ERROR_CODE = re.compile(r"^[a-z][a-z0-9-]{1,63}\Z")
244
+ _MAX_STATUS_MESSAGE = 500
245
+ _MAX_ERROR_MESSAGE = 2000
246
+ _ABSENT = object()
247
+
248
+
249
+ def _code_points(value: str) -> int:
250
+ """JSON Schema's length: code points. A Python str holds code points, a lone surrogate counting once as in JavaScript."""
251
+ return len(value)
252
+
253
+
254
+ def _own(value: Any, key: str) -> Any:
255
+ return value.get(key, _ABSENT) if isinstance(value, dict) else _ABSENT
256
+
257
+
258
+ def _days_from_civil(year: int, month: int, day: int) -> int:
259
+ year -= month <= 2
260
+ era = (year if year >= 0 else year - 399) // 400
261
+ yoe = year - era * 400
262
+ doy = (153 * (month + (-3 if month > 2 else 9)) + 2) // 5 + day - 1
263
+ return era * 146097 + yoe * 365 + yoe // 4 - yoe // 100 + doy - 719468
264
+
265
+
266
+ def utc_time(value: Any) -> Optional[int]:
267
+ """A UTC instant (`interop-common#/$defs/utcInstant`) as milliseconds since the epoch, or None: the pattern and a
268
+ day that exists, as the contract's strictTime reads it."""
269
+ match = _UTC_INSTANT.match(value) if isinstance(value, str) else None
270
+ if not match:
271
+ return None
272
+ year, month, day, hour, minute, second = (int(part) for part in match.groups()[:6])
273
+ leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
274
+ days = [31, 29 if leap else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
275
+ if not 1 <= month <= 12 or not 1 <= day <= days[month - 1] or hour > 23 or minute > 59:
276
+ return None
277
+ millis = int((match.group(7) or "").ljust(3, "0"))
278
+ return ((_days_from_civil(year, month, day) * 24 + hour) * 60 + minute) * 60000 + second * 1000 + millis
279
+
280
+
281
+ def quota_data_ok(data: Any) -> bool:
282
+ """Whether `data` is quota-exhausted's: closed, `retryable` a boolean, `resetsAt` a UTC instant, only with retryable true."""
283
+ retryable = _own(data, "retryable")
284
+ if not isinstance(data, dict) or not isinstance(retryable, bool):
285
+ return False
286
+ if any(name not in ("retryable", "resetsAt") for name in data):
287
+ return False
288
+ if "resetsAt" not in data:
289
+ return True
290
+ return retryable and utc_time(data["resetsAt"]) is not None
291
+
292
+
293
+ def _job_of(answer: Any) -> Optional[Dict[str, Any]]:
294
+ job = _own(answer, "job")
295
+ job_id = _own(job, "id")
296
+ if not isinstance(job, dict) or not isinstance(job_id, str) or not job_id or _own(job, "status") not in JOB_STATES:
297
+ return None
298
+ return job
299
+
300
+
301
+ def read_job_pause(answer: Any) -> Dict[str, Any]:
302
+ """Whether a fabric.job.get answer ({job}) is a job a limit paused: working, its statusMessage `quota-exhausted` or
303
+ `quota-exhausted until <UTC instant>`, either optionally followed by `: <text>`. One that starts quota-exhausted and
304
+ breaks the grammar is malformed."""
305
+ job = _job_of(answer)
306
+ message = _own(job, "statusMessage")
307
+ if job is None or job.get("status") != "working" or not isinstance(message, str) or not message.startswith(QUOTA_EXHAUSTED):
308
+ return {"paused": False}
309
+ match = _PAUSE.match(message)
310
+ if not match or _code_points(message) > _MAX_STATUS_MESSAGE:
311
+ return {"paused": False, "malformed": True}
312
+ until = match.group(1)
313
+ if until is None:
314
+ return {"paused": True}
315
+ return {"paused": True, "resetsAt": until} if utc_time(until) is not None else {"paused": False, "malformed": True}
316
+
317
+
318
+ def job_caller_action(answer: Any, now: Any = _ABSENT) -> Dict[str, Any]:
319
+ """What a caller does with one fabric.job.get or fabric.job.cancel answer at `now`, a UTC instant: poll, wait, answer,
320
+ done, cancelled, resend (under the same key), person, reconcile, failed or invalid. Never "submit anew"."""
321
+ at = utc_time(now)
322
+ job = _job_of(answer)
323
+ if at is None or job is None:
324
+ return {"action": "invalid"}
325
+ status = job["status"]
326
+ if status == "working":
327
+ pause = read_job_pause(answer)
328
+ if "malformed" in pause:
329
+ return {"action": "invalid"}
330
+ if not pause["paused"]:
331
+ return {"action": "poll"}
332
+ if "resetsAt" not in pause:
333
+ return {"action": "wait"}
334
+ return {"action": "wait", "notBefore": pause["resetsAt"]} if utc_time(pause["resetsAt"]) > at else {"action": "poll"}
335
+ if status in ("input_required", "completed", "cancelled"):
336
+ return {"action": {"input_required": "answer", "completed": "done", "cancelled": "cancelled"}[status]}
337
+ error = _own(job, "error")
338
+ code = _own(error, "code")
339
+ message = _own(error, "message")
340
+ code_ok = (isinstance(code, str) and bool(_ERROR_CODE.match(code))) or _is_integer(code)
341
+ shape_ok = isinstance(error, dict) and all(name in ("code", "message", "data") for name in error)
342
+ if not shape_ok or not code_ok or not isinstance(message, str) or not message or _code_points(message) > _MAX_ERROR_MESSAGE:
343
+ return {"action": "invalid"}
344
+ if code == OUTCOME_UNKNOWN:
345
+ return {"action": "reconcile"}
346
+ if code == LIMIT_REACHED:
347
+ return {"action": "person"}
348
+ if code != QUOTA_EXHAUSTED:
349
+ return {"action": "failed"}
350
+ data = _own(error, "data")
351
+ if not quota_data_ok(data):
352
+ return {"action": "invalid"}
353
+ if not data["retryable"]:
354
+ return {"action": "person"}
355
+ if "resetsAt" in data and utc_time(data["resetsAt"]) > at:
356
+ return {"action": "resend", "notBefore": data["resetsAt"]}
357
+ return {"action": "resend"}
358
+
359
+
360
+ def _is_integer(value: Any) -> bool:
361
+ """JavaScript's Number.isInteger for a JSON value."""
362
+ if isinstance(value, bool):
363
+ return False
364
+ if isinstance(value, int):
365
+ return True
366
+ return isinstance(value, float) and value == value and value not in (float("inf"), float("-inf")) and value == int(value)
367
+
368
+
369
+ def intent_idempotency_key(intent: Any) -> Optional[str]:
370
+ """The idempotency key of an intent {agentId, capability, input, request?}: a UUID of version 8 from the first 16
371
+ bytes of the SHA-256 of its RFC 8785 form. The same intent gives the same key on every resend and machine; None
372
+ for another shape or an input with no RFC 8785 form."""
373
+ import hashlib
374
+ import fabric_delegation as _fd # noqa: E402 - beside this file: RFC 8785
375
+ names = ("agentId", "capability", "input", "request")
376
+ if not isinstance(intent, dict) or any(name not in names for name in intent):
377
+ return None
378
+ agent_id, capability, given = intent.get("agentId"), intent.get("capability"), intent.get("input")
379
+ if not isinstance(agent_id, str) or not agent_id or not isinstance(capability, str) or not capability or not isinstance(given, dict):
380
+ return None
381
+ form = {"agentId": agent_id, "capability": capability, "input": given}
382
+ if "request" in intent:
383
+ request = intent["request"]
384
+ if not isinstance(request, str) or not request or _code_points(request) > 256:
385
+ return None
386
+ form["request"] = request
387
+ try:
388
+ canonical = _fd.jcs(form)
389
+ except (TypeError, ValueError, RecursionError):
390
+ return None
391
+ raw = bytearray(hashlib.sha256(canonical.encode("utf-8")).digest()[:16])
392
+ raw[6] = (raw[6] & 0x0F) | 0x80
393
+ raw[8] = (raw[8] & 0x3F) | 0x80
394
+ hexed = raw.hex()
395
+ return "%s-%s-%s-%s-%s" % (hexed[:8], hexed[8:12], hexed[12:16], hexed[16:20], hexed[20:])
396
+
397
+
398
+ def reads_null_usage(request_meta: Any) -> bool:
399
+ """Whether a provider may write null and partial in usage toward this caller: its _meta.protocols names fabric-interop/0.2."""
400
+ protocols = _own(request_meta, "protocols")
401
+ return isinstance(protocols, list) and INTEROP_NULL_USAGE in protocols
402
+
403
+
404
+ def request_meta(traceparent: Optional[str] = None) -> Dict[str, Any]:
405
+ """A request's `_meta` from this kit as a caller: its trace, and the interop revisions it reads (DEC-0087)."""
406
+ meta: Dict[str, Any] = {"protocols": list(PROTOCOLS_READ)}
407
+ if parse_traceparent(traceparent):
408
+ meta["traceparent"] = traceparent
409
+ return meta
410
+
411
+
412
+ def usage_findings(usage: Any, at: str) -> List[Dict[str, str]]:
413
+ """FAC-SEM-119 on a usage: partial false asserts every number known, so it stands beside no null."""
414
+ if not isinstance(usage, dict) or usage.get("partial", _ABSENT) is not False:
415
+ return []
416
+ unknown = [name for name in USAGE_NUMBERS if name in usage and usage[name] is None]
417
+ if not unknown:
418
+ return []
419
+ return [{"code": "FAC-SEM-119", "instancePath": "%s/partial" % at,
420
+ "message": "partial is false while %s is null: an unknown number makes the usage partial" % ", ".join(unknown)}]
421
+
422
+
423
+ def usage_toward(usage: Any, request_meta: Any = None) -> Dict[str, Any]:
424
+ """The usage a provider writes toward a caller (the gate): toward a reader of fabric-interop/0.2, null for a number
425
+ nobody measured and partial true beside it; toward any other, the measured lower bound (null as 0, no partial)."""
426
+ if reads_null_usage(request_meta):
427
+ checked = _usage(usage, nulls=True)
428
+ if any(checked.get(name, 0) is None for name in USAGE_NUMBERS) and "partial" not in checked:
429
+ checked["partial"] = True
430
+ return checked
431
+ if not isinstance(usage, dict):
432
+ raise InteropError("usage must be an object.")
433
+ lower = {key: (0 if value is None else value) for key, value in usage.items() if key != "partial"}
434
+ return _usage(lower)
435
+
436
+
437
+ def _job_findings(value: Any) -> List[Dict[str, str]]:
438
+ findings: List[Dict[str, str]] = []
439
+ if "malformed" in read_job_pause(value):
440
+ findings.append({"code": "FAC-SEM-119", "instancePath": "/job/statusMessage",
441
+ "message": "a working job's message that starts quota-exhausted reads `quota-exhausted`, `quota-exhausted until <UTC instant>`, then optionally `: <text>`, at most 500 characters"})
442
+ error = _own(_own(value, "job"), "error")
443
+ data = _own(error, "data")
444
+ resets_at = _own(data, "resetsAt")
445
+ if _own(error, "code") == QUOTA_EXHAUSTED and isinstance(resets_at, str) and utc_time(resets_at) is None:
446
+ findings.append({"code": "FAC-SEM-119", "instancePath": "/job/error/data/resetsAt", "message": "resetsAt is a UTC instant on a day that exists"})
447
+ result = _own(_own(value, "job"), "result")
448
+ findings.extend(usage_findings(_own(result, "usage"), "/job/result/usage"))
449
+ return findings
450
+
451
+
452
+ def job_limit_rules(kind: str, value: Any) -> Optional[List[Dict[str, str]]]:
453
+ """FAC-SEM-119 (kinds interop-job, usage and result) and FAC-SEM-120 (kind job-caller, {answer, now, action}: the
454
+ act equals job_caller_action's). None for another kind."""
455
+ if kind == "interop-job":
456
+ return _job_findings(value)
457
+ if kind == "usage":
458
+ return usage_findings(value, "")
459
+ if kind == "result":
460
+ return usage_findings(_own(value, "usage"), "/usage")
461
+ if kind == "job-caller":
462
+ expected = job_caller_action(_own(value, "answer"), _own(value, "now"))
463
+ if json.dumps(_own(value, "action"), sort_keys=True, default=str) == json.dumps(expected, sort_keys=True):
464
+ return []
465
+ return [{"code": "FAC-SEM-120", "instancePath": "/action", "message": "the caller answers %s as jobCallerAction does" % json.dumps(expected, separators=(",", ":"))}]
466
+ return None
467
+
468
+
469
+ def pause_message(resets_at: Optional[str] = None, text: Optional[str] = None) -> str:
470
+ """A paused job's statusMessage in the pause grammar; raises InteropError for one a reader would call malformed."""
471
+ message = QUOTA_EXHAUSTED + ("" if resets_at is None else " until %s" % resets_at) + ("" if text is None else ": %s" % text)
472
+ if "malformed" in read_job_pause({"job": {"id": "x", "status": "working", "statusMessage": message}}):
473
+ raise InteropError("A pause reads quota-exhausted [until <UTC instant, Z, at most 3 fraction digits>][: <text>], at most 500 characters.")
474
+ return message
475
+
476
+
477
+ def repeated_key_decision(job: Any, now: Any, limit_holds: Optional[bool] = None, approval_granted: bool = False) -> str:
478
+ """The provider's answer to a repeated key: "run" (the same intent runs again, once) or "same" (answer with that job).
479
+ A quota-exhausted job runs again once the limit no longer holds: `limit_holds` False when the provider knows it, else
480
+ retryable with its reset passed at `now`; a limit-reached job once its approval is granted; anything else, and
481
+ outcome-unknown above all, never."""
482
+ if not isinstance(job, dict) or job.get("status") != "failed":
483
+ return "same"
484
+ error = _own(job, "error")
485
+ code = _own(error, "code")
486
+ if code == LIMIT_REACHED:
487
+ return "run" if approval_granted is True else "same"
488
+ if code != QUOTA_EXHAUSTED:
489
+ return "same"
490
+ if limit_holds is not None:
491
+ return "run" if limit_holds is False else "same"
492
+ data = _own(error, "data")
493
+ at = utc_time(now)
494
+ if not quota_data_ok(data) or not data["retryable"] or at is None:
495
+ return "same"
496
+ return "run" if "resetsAt" not in data or utc_time(data["resetsAt"]) <= at else "same"
497
+ # #endregion job-limits-kit
498
+
499
+
217
500
  # --- C3.3 awaiting a choice ------------------------------------------------------------
218
501
 
219
502
  def form_request(message: str, properties: Dict[str, Dict[str, Any]], required: Optional[List[str]] = None) -> Dict[str, Any]:
@@ -279,8 +562,19 @@ class JobStore:
279
562
  return dict(record["job"])
280
563
 
281
564
  def create(self, capability: str, arguments: Any, traceparent: Optional[str] = None,
282
- poll_interval_ms: Optional[int] = None, delegation: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
283
- """Start a job; returns the handle a job-capable tool puts in structuredContent."""
565
+ poll_interval_ms: Optional[int] = None, delegation: Optional[Dict[str, Any]] = None, *,
566
+ caller: Optional[str] = None, idempotency_key: Optional[str] = None, now: Optional[str] = None,
567
+ limit_holds: Optional[bool] = None, approval_granted: bool = False) -> Dict[str, Any]:
568
+ """Start a job; returns the handle a job-capable tool puts in structuredContent.
569
+
570
+ With `idempotency_key` the key is kept per `caller` (the authenticated caller, DEC-0087): a key that already names
571
+ a job starts nothing and raises InteropError, unless `repeat` would answer "run" (a limit no longer holds, an
572
+ approval granted); then the key names the new job."""
573
+ if idempotency_key is not None:
574
+ decision = self.repeat(caller, idempotency_key, now if now is not None else _fs.now_iso(),
575
+ limit_holds=limit_holds, approval_granted=approval_granted)
576
+ if decision["decision"] == "same":
577
+ raise InteropError("Key %s already names job %s; answer with that job (JobStore.repeat)." % (idempotency_key, decision["job"]["id"]))
284
578
  job_id = "job_" + secrets.token_hex(12)
285
579
  job: Dict[str, Any] = {"id": job_id, "status": "working"}
286
580
  if poll_interval_ms:
@@ -290,8 +584,32 @@ class JobStore:
290
584
  if delegation: # DEC-0039: who started it; only they read, cancel or answer it
291
585
  private["delegation"] = {"principal": delegation.get("principal"), "caller": delegation.get("caller")}
292
586
  self._save({"job": job, "private": private})
587
+ if idempotency_key is not None:
588
+ _fs.atomic_write(self._key_path(caller, idempotency_key), (json.dumps({"job": job_id}) + "\n").encode(), 0o600)
293
589
  return {"job": {"id": job_id, "status": "working"}}
294
590
 
591
+ def _key_path(self, caller: Optional[str], key: str) -> Path:
592
+ import hashlib
593
+ if not isinstance(key, str) or not key:
594
+ raise InteropError("An idempotency key is a non-empty string.")
595
+ name = hashlib.sha256(json.dumps([caller, key], separators=(",", ":"), ensure_ascii=False).encode("utf-8")).hexdigest() # as JSON.stringify writes it
596
+ directory = self.dir / "keys"
597
+ _fs.ensure_private_dir(directory)
598
+ return directory / ("%s.json" % name)
599
+
600
+ def repeat(self, caller: Optional[str], key: str, now: Any, limit_holds: Optional[bool] = None,
601
+ approval_granted: bool = False) -> Dict[str, Any]:
602
+ """The answer to a key this caller sent before (DEC-0087): {"decision": "new"|"same"|"run", "job": job or None}.
603
+ "same": answer with that job, never run it again (outcome-unknown above all); "run": the intent runs again, once."""
604
+ try:
605
+ job_id = json.loads(self._key_path(caller, key).read_text(encoding="utf-8"))["job"]
606
+ job = self.get(job_id)
607
+ except (FileNotFoundError, UnknownJob):
608
+ return {"decision": "new", "job": None}
609
+ except (ValueError, KeyError, TypeError):
610
+ raise InteropError("Key %s is unreadable on disk." % key) from None
611
+ return {"decision": repeated_key_decision(job, now, limit_holds, approval_granted), "job": job}
612
+
295
613
  def get(self, job_id: str) -> Dict[str, Any]:
296
614
  return dict(self._load(job_id)["job"])
297
615
 
@@ -352,8 +670,37 @@ class JobStore:
352
670
  envelope = dict(envelope, trace={"traceparent": stored})
353
671
  return self._transition(job_id, "completed", result=envelope)
354
672
 
355
- def fail(self, job_id: str, code: Any, message: str) -> Dict[str, Any]:
356
- return self._transition(job_id, "failed", error={"code": code, "message": message})
673
+ def fail(self, job_id: str, code: Any, message: str, data: Any = None) -> Dict[str, Any]:
674
+ error: Dict[str, Any] = {"code": code, "message": message}
675
+ if data is not None:
676
+ error["data"] = data
677
+ return self._transition(job_id, "failed", error=error)
678
+
679
+ def pause(self, job_id: str, resets_at: Optional[str] = None, text: Optional[str] = None) -> Dict[str, Any]:
680
+ """A job a limit paused and its provider will resume (DEC-0087): working, its statusMessage in the pause grammar,
681
+ and pollIntervalMs reaching the reset within its cap of one hour."""
682
+ message = pause_message(resets_at, text)
683
+ record = self._load(job_id)
684
+ if resets_at is not None:
685
+ wait = utc_time(resets_at) - utc_time(_fs.now_iso())
686
+ if wait > 0:
687
+ record["job"]["pollIntervalMs"] = int(min(wait, 3600000))
688
+ self._save(record)
689
+ return self._transition(job_id, "working", statusMessage=message)
690
+
691
+ def quota_exhausted(self, job_id: str, message: str, retryable: bool, resets_at: Optional[str] = None) -> Dict[str, Any]:
692
+ """A job a limit stopped and its provider will not resume: failed, quota-exhausted, data {retryable, resetsAt?}."""
693
+ data: Dict[str, Any] = {"retryable": retryable}
694
+ if resets_at is not None:
695
+ data["resetsAt"] = resets_at
696
+ if not quota_data_ok(data):
697
+ raise InteropError("quota-exhausted data is {retryable: boolean, resetsAt?: UTC instant}, resetsAt only with retryable true.")
698
+ return self.fail(job_id, QUOTA_EXHAUSTED, message, data)
699
+
700
+ def outcome_unknown(self, job_id: str, message: str, data: Any = None) -> Dict[str, Any]:
701
+ """A job whose effect may have happened and that its provider cannot observe: failed, outcome-unknown. Its key is
702
+ never run again; only an observed result settles it."""
703
+ return self.fail(job_id, OUTCOME_UNKNOWN, message, data)
357
704
 
358
705
  def cancel(self, job_id: str) -> Dict[str, Any]:
359
706
  return self._transition(job_id, "cancelled")
@@ -540,10 +540,16 @@ def _route_event(value: Dict[str, Any]) -> List[Dict[str, str]]:
540
540
 
541
541
  def expect_probes(count: float) -> None:
542
542
  indexes = [_index_of(p) for p in probes]
543
- expected = range(int(max(count, 0))) # Array.from({length: count}): ToLength, built only where it is read
544
- if len(indexes) != len(expected) or any(index != expected[position] for position, index in enumerate(indexes)):
545
- wrong("/probes", "the walk must record candidates %s in order; it records %s"
546
- % (", ".join(map(_estate._js_string, expected)) or "none", ", ".join(map(_estate._js_string, indexes)) or "none"))
543
+ # Compared with 0...count-1 before any list is built, and the message lists at most 64 candidates, so a count from
544
+ # an index of 2**32 allocates nothing (contract route-rules.ts at DEC-0084, from this kit's report).
545
+ if _estate._same(len(indexes), count) and all(_estate._same(index, position) for position, index in enumerate(indexes)):
546
+ return
547
+ if count <= 64: # NaN is not; Array.from({length: count}) takes ToLength
548
+ expected = ", ".join(map(_estate._js_string, range(int(count) if count > 0 else 0))) or "none"
549
+ else:
550
+ expected = "0 to %s" % _estate._js_string(count - 1)
551
+ wrong("/probes", "the walk must record candidates %s in order; it records %s"
552
+ % (expected, ", ".join(map(_estate._js_string, indexes)) or "none"))
547
553
 
548
554
  if event.get("kind") == "runner-exhausted":
549
555
  expect_probes(len(candidates))
@@ -1356,13 +1356,15 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
1356
1356
  #: Each contract protocol's published revisions, oldest first (the contract's `PROTOCOL_REVISIONS`, DEC-0067); a test
1357
1357
  #: holds it equal to the contract's register at the pin.
1358
1358
  PROTOCOL_REVISIONS: Dict[str, Tuple[str, ...]] = {
1359
- "access-log": ("1",), "approval": ("1",), "activity-summary": ("1",), "estate-record": ("1",), "execution-packet": ("1",),
1359
+ "access-log": ("1",), "approval": ("1",), "activity-summary": ("1",), "enroll-token": ("1",), "entitlement": ("1",),
1360
+ "entitlement-key-set": ("1",), "estate-record": ("1",), "execution-packet": ("1",),
1360
1361
  "fabric-activity": ("0.1",), "fabric-dashboard": ("0.1",), "fabric-delegation": ("0.1",), "fabric-device": ("0.1",),
1361
- "fabric-interop": ("0.1",), "fabric-local-channel": ("0.1",), "fabric-local-runner": ("0.1",),
1362
+ "fabric-interop": ("0.1", "0.2"), "fabric-local-channel": ("0.1",), "fabric-local-runner": ("0.1",),
1362
1363
  "fabric-operator-channel": ("0.1",), "fabric-package": ("1",), "fabric-project-comms": ("0.1",), "fabric-proposal": ("1",),
1363
1364
  "fabric-provider": ("0.1",), "fabric-relay": ("0.1",), "fabric-secret-envelope": ("1",), "fabric-service": ("0.1",),
1364
- "fabric-settings-backup": ("1",), "heartbeat": ("1",), "learning-note": ("1",), "memory": ("0.1",), "pipeline": ("0.1",),
1365
- "revocation-delta": ("1",), "session-handoff": ("1",), "window-lease": ("1",),
1365
+ "fabric-settings-backup": ("1",), "heartbeat": ("1",), "learning-note": ("1",), "memory": ("0.1",), "pairing-message": ("1",), "pipeline": ("0.1",),
1366
+ "revocation-delta": ("1",), "session-handoff": ("1",), "share-registration": ("1",),
1367
+ "share-registration-receipt": ("1",), "window-lease": ("1",),
1366
1368
  }
1367
1369
  MAX_PROTOCOLS = 64
1368
1370
  _REVISION = re.compile(r"([a-z][a-z0-9.-]{0,63})/([0-9]{1,4}(?:\.[0-9]{1,4}){0,2})", re.ASCII)
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env python3
2
+ """Share grants into an organization and their registrations (contract DEC-0086), the Python kit; the Node twin is
3
+ fabric-share.mjs. Standard library only.
4
+
5
+ What a kit checks before it writes or reads them:
6
+
7
+ - FAC-SEM-117: a grant whose context is an organization's (``org:<orgId>``) is named ``urn:fabric:share:`` and 32
8
+ lower-case hex digits, carries its window with at most three fraction digits, runs one agentRef among the placements
9
+ naming it, and keeps that agentRef against the earlier revision given as ``parent`` (a move revokes the grant and
10
+ mints a new id).
11
+ - FAC-SEM-118: a ``share-registration/1`` document's instants are readable and an active window ends after it starts.
12
+ - FAC-SEM-045, the clause DEC-0086 adds: a ``grant.usage`` event carries its grant's own context, ``org:`` and the client
13
+ certificate's ``org.id`` as written. The other FAC-SEM-045 clauses (epochs, users) are the receiving server's and not
14
+ ported here.
15
+
16
+ Deriving, deciding and joining registrations (``deriveShareRegistrations``, ``decideShareRegistration``,
17
+ ``joinGrantUsage``, ``gateGrantUsage``) belong to the sharer's node and the organization server, not to this kit.
18
+ ``share-registration.schema.json`` beside this file is the contract's schema with its outside definitions brought in.
19
+ Every function answers and never raises on malformed input. Normative source: the contract's
20
+ src/share-registration.ts and docs/specification/share-registration.md.
21
+ """
22
+ # #region share-kit, docs: plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md#share-grants-into-an-organization
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ import sys
28
+ from pathlib import Path
29
+ from typing import Any, Dict, List, Optional
30
+
31
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
32
+ import fabric_estate as _estate # noqa: E402 - beside this file: FAC-SEM-110, times, show
33
+ import fabric_identifiers as _ids # noqa: E402 - beside this file: identifier equality (DEC-0076)
34
+
35
+ SHARE_REGISTRATION_TYPE = "share-registration/1"
36
+ SHARE_REGISTRATION_RECEIPT_TYPE = "share-registration-receipt/1"
37
+ #: ``common#/$defs/shareGrantId`` and ``#/$defs/orgContextId``: compared as written, the end of the text is the end.
38
+ SHARE_GRANT_ID = re.compile(r"urn:fabric:share:(?!0{32}\Z)[0-9a-f]{32}\Z")
39
+ ORG_CONTEXT = re.compile(r"org:[A-Za-z0-9][A-Za-z0-9_-]{0,127}\Z")
40
+ #: ``share-registration#/$defs/instant``: an offset, seconds 00 to 59, at most three fraction digits.
41
+ _INSTANT = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}[Tt][0-9]{2}:[0-9]{2}:[0-5][0-9](?:\.[0-9]{1,3})?(?:[Zz]|[+-][0-9]{2}:[0-9]{2})\Z")
42
+ _SUB_MILLISECOND = re.compile(r":[0-9]{2}\.[0-9]{4,}")
43
+
44
+
45
+ def _finding(code: str, at: str, message: str) -> Dict[str, str]:
46
+ return {"code": code, "instancePath": at, "message": message}
47
+
48
+
49
+ def _objects(value: Any) -> List[Any]:
50
+ return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
51
+
52
+
53
+ def _indexed(value: Any) -> List[Any]:
54
+ """The object items of a list with their index as written, so an instancePath names the item after a non-object."""
55
+ return [(item, index) for index, item in enumerate(value) if isinstance(item, dict)] if isinstance(value, list) else []
56
+
57
+
58
+ def is_share_grant_id(value: Any) -> bool:
59
+ """A share grant's id: urn:fabric:share: and 32 lower-case hex digits, not all zeros, as written."""
60
+ return isinstance(value, str) and SHARE_GRANT_ID.match(value) is not None
61
+
62
+
63
+ def is_org_context(value: Any) -> bool:
64
+ """An organization's context id, org:<orgId>, as written."""
65
+ return isinstance(value, str) and ORG_CONTEXT.match(value) is not None
66
+
67
+
68
+ def _is_org(value: Any) -> bool:
69
+ return isinstance(value, str) and value.startswith("org:")
70
+
71
+
72
+ def share_grant_id_findings(grant: Any, at: str) -> List[Dict[str, str]]:
73
+ """FAC-SEM-117 on one grant: an organization's share grant has a share grant id and a millisecond window."""
74
+ if not isinstance(grant, dict) or not _is_org(grant.get("context")):
75
+ return []
76
+ findings = []
77
+ meta = grant.get("meta")
78
+ if not is_share_grant_id(meta.get("id") if isinstance(meta, dict) else None):
79
+ findings.append(_finding("FAC-SEM-117", "%s/meta/id" % at, "a share grant into an organization is named urn:fabric:share: and 32 lower-case hex digits from a CSPRNG, never a name, an address or an estate id"))
80
+ for name in ("startsAt", "expiresAt"):
81
+ if isinstance(grant.get(name), str) and _SUB_MILLISECOND.search(grant[name]):
82
+ findings.append(_finding("FAC-SEM-117", "%s/%s" % (at, name), "a share grant into an organization carries its window with at most three fraction digits"))
83
+ return findings
84
+
85
+
86
+ def grant_findings(grant: Any, at: str = "") -> List[Dict[str, str]]:
87
+ """Kind ``grant`` as the contract's contextRules answers it: FAC-SEM-110 (DEC-0082), then FAC-SEM-117 (DEC-0086)."""
88
+ return _estate.grant_context_findings(grant, at) + share_grant_id_findings(grant, at)
89
+
90
+
91
+ def _agents_under(record: Dict[str, Any], context: Any, grant_id: Any) -> set:
92
+ """The agentRefs the placements of ``record`` in ``context`` run under the grant ``grant_id`` (by normal form)."""
93
+ placements = [placement for machine in _objects(record.get("machines")) for placement in _objects(machine.get("packages"))]
94
+ return {_estate._show(placement.get("agentRef", _estate._MISSING)) for placement in placements
95
+ if _estate._same(placement.get("context", _estate._MISSING), context)
96
+ and isinstance(grant_id, str) and _ids.same_identifier(placement.get("grant"), grant_id)}
97
+
98
+
99
+ def org_share_grants_findings(value: Any) -> List[Dict[str, str]]:
100
+ """FAC-SEM-117 on a record ``{record, parent?}``: each organization share grant's id and window, one agentRef among the
101
+ placements naming it, and against ``parent`` (the latest earlier revision that placed it) the same agentRef."""
102
+ value = value if isinstance(value, dict) else {}
103
+ record = value.get("record") if isinstance(value.get("record"), dict) else {}
104
+ parent = value.get("parent") if isinstance(value.get("parent"), dict) else None
105
+ findings: List[Dict[str, str]] = []
106
+ for grant, index in _indexed(record.get("shareGrants")):
107
+ at = "/record/shareGrants/%d" % index
108
+ findings.extend(share_grant_id_findings(grant, at))
109
+ if not _is_org(grant.get("context")):
110
+ continue
111
+ meta = grant.get("meta")
112
+ grant_id = meta.get("id") if isinstance(meta, dict) else None
113
+ refs = _agents_under(record, grant["context"], grant_id)
114
+ if len(refs) > 1:
115
+ findings.append(_finding("FAC-SEM-117", at, "the placements naming this grant run %d agents: a share grant into an organization runs one agentRef" % len(refs)))
116
+ if parent is None:
117
+ continue
118
+ before = next((old for old in _objects(parent.get("shareGrants"))
119
+ if _estate._same(old.get("context", _estate._MISSING), grant["context"]) and isinstance(old.get("meta"), dict)
120
+ and isinstance(grant_id, str) and _ids.same_identifier(old["meta"].get("id"), grant_id)), None)
121
+ earlier = _agents_under(parent, before.get("context"), grant_id) if before is not None else set()
122
+ if earlier and any(ref not in earlier for ref in refs):
123
+ findings.append(_finding("FAC-SEM-117", at, "this grant ran another agent in the earlier revision given as parent: a share grant into an organization keeps its agentRef; move the share by revoking it and minting a new id"))
124
+ return findings
125
+
126
+
127
+ def _time(value: Any) -> float:
128
+ """An instant as milliseconds, or NaN: the schema's pattern and a day that exists."""
129
+ return _estate.estate_time(value) if isinstance(value, str) and _INSTANT.match(value) else float("nan")
130
+
131
+
132
+ def registration_findings(value: Any) -> List[Dict[str, str]]:
133
+ """FAC-SEM-118 on one ``share-registration/1`` document: its instants are readable, an active window ends after it starts."""
134
+ if not isinstance(value, dict) or value.get("type") != SHARE_REGISTRATION_TYPE:
135
+ return []
136
+ names = ("startsAt", "expiresAt") if value.get("state") == "active" else ("withdrawnAt",)
137
+ unreadable = [name for name in names if _time(value.get(name)) != _time(value.get(name))]
138
+ findings = [_finding("FAC-SEM-118", "/%s" % name, "an instant is an RFC 3339 date-time with an offset, on a day that exists, with at most three fraction digits") for name in unreadable]
139
+ if value.get("state") == "active" and not unreadable and _time(value["startsAt"]) >= _time(value["expiresAt"]):
140
+ findings.append(_finding("FAC-SEM-118", "/expiresAt", "an active registration's window ends after it starts"))
141
+ return findings
142
+
143
+
144
+ def grant_usage_context_findings(value: Any) -> List[Dict[str, str]]:
145
+ """FAC-SEM-045's DEC-0086 clause on ``{binding, batch}``: every ``grant.usage`` event's ``data.context`` is ``org:`` and
146
+ the certificate's ``org.id`` as written; another organization's usage is refused as attribution."""
147
+ value = value if isinstance(value, dict) else {}
148
+ binding = value.get("binding") if isinstance(value.get("binding"), dict) else {}
149
+ org = binding["org"].get("id") if isinstance(binding.get("org"), dict) else None
150
+ batch = value.get("batch") if isinstance(value.get("batch"), dict) else {}
151
+ findings = []
152
+ for position, event in enumerate(_objects(batch.get("events"))): # the index among the objects, as the contract counts it
153
+ if event.get("kind") != "grant.usage":
154
+ continue
155
+ context = event["data"].get("context", _estate._MISSING) if isinstance(event.get("data"), dict) else _estate._MISSING
156
+ if not isinstance(org, str) or not isinstance(context, str) or context != "org:" + org:
157
+ findings.append(_finding("FAC-SEM-045", "/batch/events/%d/data/context" % position, "a grant's usage carries its grant's own context, org: and the client certificate's org.id; another organization's is refused as attribution"))
158
+ return findings
159
+
160
+
161
+ def share_rules(kind: str, value: Any) -> Optional[List[Dict[str, str]]]:
162
+ """Kinds ``grant`` (FAC-SEM-110, 117), ``org-share-grants`` (117 with parent), ``share-registration`` (118) and
163
+ ``device-attribution`` (045's grant.usage clause only); None for another kind."""
164
+ if kind == "grant":
165
+ return grant_findings(value, "")
166
+ if kind == "org-share-grants":
167
+ return org_share_grants_findings(value)
168
+ if kind == "share-registration":
169
+ return registration_findings(value)
170
+ if kind == "device-attribution":
171
+ return grant_usage_context_findings(value)
172
+ return None
173
+ # #endregion share-kit