@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.
- package/CHANGELOG.md +48 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +45 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +37 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +13 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +129 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +67 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +252 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +5 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +4 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +109 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +194 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +82 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +356 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +10 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/share-registration.schema.json +218 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +13 -4
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +3 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +20 -5
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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":
|
|
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
|
|
283
|
-
|
|
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
|
-
|
|
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")
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py
CHANGED
|
@@ -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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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))
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -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",), "
|
|
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",), "
|
|
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
|