@passioncode-ai/fabric-agent-adapter 0.47.0 → 0.48.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 (26) hide show
  1. package/CHANGELOG.md +40 -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 +2 -2
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +15 -2
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +4 -2
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +5 -1
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +31 -4
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +42 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +32 -3
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +2 -2
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +51 -7
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -4
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +8 -1
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +2 -2
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +1 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +83 -9
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +162 -10
  24. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  25. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  26. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.48.0 (2026-10-23)
4
+
5
+ ### Changed
6
+
7
+ - **Pinned to contract `2f020a6`**, its first tagged release `v0.1.0+2026-10-23` (FAA-109; `87d384f` stays
8
+ supported): DEC-0107, DEC-0109, DEC-0110 and DEC-0111. Every vendored vector is regenerated from the contract's own
9
+ functions at the pin.
10
+ - **`check_service.py` `well-known.requires-token` follows DEC-0109 (CT-114).** A remote service's no-token `401` may
11
+ carry an identity proxy's page: it passes when the page neither parses as the well-known document nor names the
12
+ service's id, `id.instance`, build or pid. The service's host name is taken out first, and a value shorter than four
13
+ characters is not looked for; a copy of the document anywhere inside a JSON body fails. The rule is recorded on every
14
+ path, a later request's timeout included. Fabric Inbox's remote placement on this machine now passes 34 of 34 rules (it failed
15
+ this one before).
16
+ - **A spending limit counts money only (DEC-0110, CT-115)**, both twins: `budget.spentUsd` and a machine limit's
17
+ `spentUsd` leave out a `subscription` route's `costUsd`, an estimate at list price; a window spent only on a
18
+ subscription spends `0`. New: `usage_spend` / `usageSpend`, the contract's `usageSpend`, held to 42 cases its own
19
+ function answers (`test/fixtures/contract/usage-spend-vectors.json`). A cost or a count past the double range answers
20
+ `None` in Python, as the contract answers `undefined`. The day's `costUsd` still sums every route.
21
+ - **Both estate readers read `deltaAnchor` (DEC-0107)**: `FAC-SEM-137` in the revision rules and the verdict, and
22
+ `delta_anchor_of` / `deltaAnchorOf`, held to the contract's fixtures and hostile variants. `FAC-SEM-075`'s message
23
+ says "a secret envelope", as the contract's.
24
+ - **The protocol register gains `call-record/1`, `credential-decision/1`, `credential-request/1`,
25
+ `credential-revocation/1` and `fabric-credential-envelope/1`** (DEC-0111), both twins. Credentials on request are a
26
+ node's and Project Observatory's duty, not the kit's.
27
+ - `references/remote-placement.md`, `usage.md`, `estate-record.md` and `protocol.md` say so.
28
+
29
+ ## 0.47.1 (2026-10-23)
30
+
31
+ ### Fixed
32
+
33
+ - **The three risks of 0.47.0's blind unit reading** (FAA-107; the reading passed with nothing broken).
34
+ - The Python estate reader matched a `view` machine's id against a secret's `from` with `===`, where the contract
35
+ keeps the ids in a `Set` (SameValueZero), so a NaN id never matched a NaN `from`. No JSON the kit loads carries
36
+ NaN; a Python caller building the record itself could. The core gains `set_has` (a Set's `has`), used by
37
+ `FAC-SEM-134`, and `same`'s docstring no longer claims SameValueZero. The Node twin already matched.
38
+ - `profile-forbids`' 403 is asserted in both delegation kits (status, no `WWW-Authenticate`, the message); it was
39
+ held only by the default branch.
40
+ - Nine vendored `FAC-SEM-134` variants put a `view` machine id of another JSON type (null, a number, a boolean, an
41
+ object, an array, `-0`) against each secret's `from`, answered by the contract's own rules.
42
+
3
43
  ## 0.47.0 (2026-10-23)
4
44
 
5
45
  ### Changed
package/README.md CHANGED
@@ -274,7 +274,7 @@ Scaffolding is non-destructive by default. It writes only the paths declared in
274
274
  The default pin for new bundles is [`fabric-contract.lock.json`](fabric-contract.lock.json):
275
275
 
276
276
  - version: `0.1.0`
277
- - commit: `87d384ffce2757081b7ab227e5dad3dddd08328f`
277
+ - commit: `2f020a696453ae299b123517f14957f6a1354379`
278
278
 
279
279
  Every other mention of the default contract revision (this section, the skills' metadata, the
280
280
  skill card, `adapt_project.py`) must equal it; `python3 test/validate.py` fails a tree
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/fabric-agent-adapter",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
4
4
  "description": "Create Fabric-compatible agents, run them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract. This package is the installer CLI.",
5
5
  "scripts": {
6
6
  "test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test 'test/*.test.mjs'"
@@ -3,7 +3,7 @@
3
3
  "name": "fabric-agent-adapter",
4
4
  "displayName": "Fabric Agent Adapter",
5
5
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles; and teach a coding agent to work in a project through Fabric (fabric-mcp, fabric_whoami, claim and release), with Claude Code session hooks.",
6
- "version": "0.47.0",
6
+ "version": "0.48.0",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",
9
9
  "url": "https://passioncode.ai/"
@@ -5,9 +5,9 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.47.0"
8
+ version: "0.48.0"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "87d384ffce2757081b7ab227e5dad3dddd08328f"
10
+ contract-commit: "2f020a696453ae299b123517f14957f6a1354379"
11
11
  ---
12
12
 
13
13
  # Adapting projects to Fabric
@@ -76,7 +76,7 @@ Use exactly:
76
76
 
77
77
  - contract version `0.1.0`;
78
78
  - repository `https://github.com/passioncode-ai/fabric-agent-contract`;
79
- - commit `87d384ffce2757081b7ab227e5dad3dddd08328f`.
79
+ - commit `2f020a696453ae299b123517f14957f6a1354379`.
80
80
 
81
81
  This is the default for new bundles. When checking an issued legacy bundle, preserve its
82
82
  explicit supported lock and use that exact clean checkout. Never silently upgrade or
@@ -22,10 +22,10 @@ import fabric_secrets as _secrets # noqa: E402 - the contract's credential dete
22
22
 
23
23
  CONTRACT_VERSION = "0.1.0"
24
24
  CONTRACT_REPOSITORY = "https://github.com/passioncode-ai/fabric-agent-contract"
25
- CONTRACT_COMMIT = "87d384ffce2757081b7ab227e5dad3dddd08328f"
25
+ CONTRACT_COMMIT = "2f020a696453ae299b123517f14957f6a1354379"
26
26
  # #region supported-contract-revisions — docs: README.md#contract-pin
27
27
  # Only this declaration may name a legacy revision in live files.
28
- SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "0a4ce773c4b9e6bec40c6387eb3f988af7152c0e", "e742016dc396aa31e5a13a893489e43f15786c69", "bfa9a67aa3c6cb808c093e7d83cd59799f1ee7d0", "c55f2d5a11c9a0197e958e87212fca7ae1154769", "07042181bfae2a481aa0da1a5651f24c76669c48", "d8146cfb0a1c74fc6432284c4356bb0c864b37b5", "82e163665e156a08aa3b92e4711015582c06162c", "1636fa97262f4a26e2086572d540c2ee701e0010", "f00a7685e3861c27f7899c033fb850a145a1708f", "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b", "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0", "b291bf09e0a9f9fd3c16cf540832cd021203b7d1", "0cacd08b0e95b6abf131eae1206b60792138bc3c", "2eb91c00e852d9c31d804310d38fad91589fcbac", "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5", "a83bec6b47776af688402c18efec507143061f68", "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
28
+ SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "87d384ffce2757081b7ab227e5dad3dddd08328f", "0a4ce773c4b9e6bec40c6387eb3f988af7152c0e", "e742016dc396aa31e5a13a893489e43f15786c69", "bfa9a67aa3c6cb808c093e7d83cd59799f1ee7d0", "c55f2d5a11c9a0197e958e87212fca7ae1154769", "07042181bfae2a481aa0da1a5651f24c76669c48", "d8146cfb0a1c74fc6432284c4356bb0c864b37b5", "82e163665e156a08aa3b92e4711015582c06162c", "1636fa97262f4a26e2086572d540c2ee701e0010", "f00a7685e3861c27f7899c033fb850a145a1708f", "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b", "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0", "b291bf09e0a9f9fd3c16cf540832cd021203b7d1", "0cacd08b0e95b6abf131eae1206b60792138bc3c", "2eb91c00e852d9c31d804310d38fad91589fcbac", "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5", "a83bec6b47776af688402c18efec507143061f68", "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
29
29
  # #endregion supported-contract-revisions
30
30
  INTEROP_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
31
31
  MCP_REVISION = "2026-07-28"
@@ -14,10 +14,10 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
14
14
  compatibility: Python 3.9+ or Node.js 20+ for the kits (Node.js 22+ for a Linux service's instance lock); the probe needs Python 3.9+. launchd steps are macOS-only; Linux services install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
15
15
  metadata:
16
16
  author: PassionCode.ai
17
- version: "0.47.0"
17
+ version: "0.48.0"
18
18
  contract-version: "0.1.0"
19
19
  extension: "fabric-service/0.1"
20
- extension-commit: "87d384ffce2757081b7ab227e5dad3dddd08328f"
20
+ extension-commit: "2f020a696453ae299b123517f14957f6a1354379"
21
21
  ---
22
22
 
23
23
  # Building Fabric services
@@ -3,7 +3,8 @@
3
3
  Fabric Agent Contract DEC-0055, `docs/specification/estate-record.md`, schema `schemas/estate-record.schema.json`,
4
4
  rules `FAC-SEM-072…076`, with contexts (DEC-0082, `docs/specification/contexts.md`, `FAC-SEM-110`, `FAC-SEM-111`) and a
5
5
  partner's scoped publisher key (DEC-0090, `docs/specification/partner-channel.md`, `FAC-SEM-122`) and each machine's
6
- profile (DEC-0106, `docs/specification/machine-calls.md`, `FAC-SEM-134`).
6
+ profile (DEC-0106, `docs/specification/machine-calls.md`, `FAC-SEM-134`), and the revocation delta's head a
7
+ revision vouches for (DEC-0107, `deltaAnchor`, `FAC-SEM-137`).
7
8
  One revision of one person's estate lists:
8
9
  - the passkeys that sign it;
9
10
  - the machines, with their device keys, a certificate of each machine's hub key and, optionally, a profile;
@@ -21,6 +22,7 @@ derived from its own digest. Python: [`scripts/fabric_estate.py`](../scripts/fab
21
22
  - Trusting a delegation key
22
23
  - Contexts: one agent for you and for an organization
23
24
  - Machine profiles: full, member, view
25
+ - The delta anchor
24
26
  - The estate ceremonies
25
27
  - Share grants into an organization
26
28
  - Checking a record you produce
@@ -59,7 +61,8 @@ It checks, in the order of the ER table:
59
61
  7. **The window.** At most 30 days. A revision may be issued up to 60 s ahead of the verifier's clock (DEC-0066,
60
62
  `CLOCK_SKEW_SECONDS`); earlier than that it is `not-yet-valid`.
61
63
  8. **The record's own rules.** A violation is `rule-violation` with its `codes`, `FAC-SEM-072…076`, `FAC-SEM-110`,
62
- `FAC-SEM-111`, `FAC-SEM-122`, `FAC-SEM-134` and `FAC-SEM-092`
64
+ `FAC-SEM-111`, `FAC-SEM-122`, `FAC-SEM-134` and `FAC-SEM-092`, and against the parent `FAC-SEM-114` and
65
+ `FAC-SEM-137`
63
66
  (each passkey the record lists is a signing credential, its origins within its RP ID).
64
67
 
65
68
  Dates are strict RFC 3339: a `T`, an offset, and a day that exists. `:60` and a space are refused. Passkey JWK `x`
@@ -160,6 +163,16 @@ own profile vectors (`fixtures/vectors/machine-call.json`) and hostile inputs (`
160
163
  service is one yet (FAA-106). A delegation verifier still names the refusal: `profile-forbids` is a DL-19 code
161
164
  ([delegation](delegation.md)).
162
165
 
166
+ ## The delta anchor
167
+
168
+ DEC-0107 lets a revision name the revocation delta's head it vouches for: `deltaAnchor {sequence, sha256}`. Every
169
+ revocation of the delta's entries up to it is in the record. `FAC-SEM-137` keeps it from moving back: once a parent
170
+ anchors the delta, its next revision anchors it too, at the parent's sequence or later, with the parent's hash at the
171
+ same sequence. `delta_anchor_of(record)` / `deltaAnchorOf(record)` answers the anchor, or `None` / `undefined` when the
172
+ record names none or one of another shape. Applying the delta against the anchor (`anchorDelta`) is a machine's duty
173
+ when it applies revisions; the kit verifies the revision and does not hold a delta. Fabric writes `deltaAnchor` only
174
+ once every reader is pinned at or after DEC-0107: update a copied kit first, as for `profile`.
175
+
163
176
  ## The estate ceremonies
164
177
 
165
178
  Contract DEC-0084: create a network, add a machine nearby (a six-digit comparison code) or headless (an enroll token),
@@ -1,6 +1,6 @@
1
1
  # fabric-service/0.1: wire reference
2
2
 
3
- Pinned to `fabric-agent-contract` commit `87d384ffce2757081b7ab227e5dad3dddd08328f`
3
+ Pinned to `fabric-agent-contract` commit `2f020a696453ae299b123517f14957f6a1354379`
4
4
  (`docs/specification/service.md`, DEC-0015; the usage report DEC-0021; Windows and Linux supervision
5
5
  DEC-0032 and DEC-0033; the operator channel DEC-0034 and DEC-0035; the embedded dashboard DEC-0036;
6
6
  delegation and the operator session's lifetime DEC-0039; the meter's call window DEC-0041; session handoff DEC-0042;
@@ -12,7 +12,9 @@ by normal form DEC-0076; the project board's directory page DEC-0077 and a repla
12
12
  DEC-0096; a schema pattern never uses a bare dot DEC-0097; the board as Fabric 0.3.9 serves it DEC-0098 and DEC-0102, a
13
13
  `processor-hosted` peer DEC-0099, three device protocols in the register DEC-0101; a call queue's `queue-full` and
14
14
  the `agent-directory`, `fabric-load` and `service-load` protocols DEC-0104; a machine's `profile` in the estate record,
15
- `profile-forbids` and the `fabric-agent-call` protocol DEC-0106). The contract's schemas are normative;
15
+ `profile-forbids` and the `fabric-agent-call` protocol DEC-0106; the delta anchor DEC-0107; a remote service's
16
+ no-token `401` DEC-0109; money apart from a subscription's estimate DEC-0110; the five credential and call-record
17
+ protocols of DEC-0111 in the register). The contract's schemas are normative;
16
18
  this page is the working summary. Extension key:
17
19
  `https://fabric.passioncode.ai/agent-contract/extensions/service/0.1`.
18
20
 
@@ -64,6 +64,16 @@ router answers it, usually `404` or `421`. The probe counts that as the refusal
64
64
  the body is not the well-known document. The Origin and cross-site checks name the right `Host`,
65
65
  reach the service, and still need its own `403`.
66
66
 
67
+ Without the token the well-known route answers `401` with none of the service's data (DEC-0109). The kit's server
68
+ answers it with an empty body. An identity proxy in front of the service (Cloudflare Access, for one) may answer that
69
+ `401` with its own sign-in page: `well-known.requires-token` passes it when the page neither parses as the well-known
70
+ document nor names the service's identity values (its id, `id.instance`, build, or pid, as whole words). The probe
71
+ takes the service's own host name out first, because a sign-in page names the address it guards, and does not look for
72
+ a value shorter than four characters, because a platform's pid is often `1`. A copy of the document anywhere inside a JSON body
73
+ fails, however short the id. One false FAIL remains possible: when the document was read, a proxy page that happens to
74
+ contain the service's pid as a whole number (an inline script's constant, say) fails the rule; the evidence names the
75
+ value it found.
76
+
67
77
  ## What a host does and will not do
68
78
 
69
79
  | Situation | Host |
@@ -78,7 +78,11 @@ ledger.report(service_id=ID, budget={"period": "month", "limitUsd": 100}, budget
78
78
  ```
79
79
 
80
80
  - A machine limit with a `period` and no `spentUsd` is counted by the kit from the receipts, the
81
- way `budget` is, so `budget` and its twin in `budgets` never disagree. A per-order limit
81
+ way `budget` is, so `budget` and its twin in `budgets` never disagree. Both count money only (DEC-0110):
82
+ a `subscription` route's `costUsd` is an estimate at list price, never spent money, so a window
83
+ spent only on a subscription spends `0`, and money is `null` only while no call off a subscription
84
+ is priced. `usage_spend(rows)` / `usageSpend(rows)` is the contract's `usageSpend` over
85
+ `days[].byModel[]` rows: money and the subscription's estimate apart. A per-order limit
82
86
  (`per_job`, `approval`) gets `spentUsd: null`. Every other limit states its own spend.
83
87
  - `scope` is `machine`, `project`, `pool` or `job`; a `project` or `pool` limit names its
84
88
  `subject`. A limit has at most one window (`period`, `windowSeconds` or `since`), and it agrees
@@ -9,7 +9,7 @@
9
9
 
10
10
  A remote placement (DEC-0019 — an online agent or dashboard at an https origin) is probed over
11
11
  TLS with the certificate verified: the well-known document must refuse a request without the
12
- token (401, empty body), the guards must refuse a foreign Host/Origin and cross-site requests,
12
+ token (401 with none of the service's data: an empty body, or an identity proxy's page, DEC-0109), the guards must refuse a foreign Host/Origin and cross-site requests,
13
13
  and the session cookie must be __Host- and Secure. Loopback, launchd, lock and state rules do
14
14
  not apply to it and are reported NOT_RUN with that reason.
15
15
 
@@ -62,7 +62,7 @@ from check_service_rules import PLATFORM_HOST_REFUSALS # noqa: E402,F401 - move
62
62
  from check_service_rules import plist_problems # noqa: E402,F401 - moved (FAA-65)
63
63
  from check_service_rules import marker_problem # noqa: E402,F401 - moved (FAA-65)
64
64
  from check_service_rules import priority_problems # noqa: E402,F401 - moved (FAA-65)
65
- from check_service_rules import guard_verdict # noqa: E402,F401 - moved (FAA-65)
65
+ from check_service_rules import guard_verdict, no_token_verdict # noqa: E402,F401 - moved (FAA-65)
66
66
  from check_service_rules import board_responder_verdict # noqa: E402,F401 - moved (FAA-65)
67
67
  from check_service_rules import session_lifetime_problem # noqa: E402,F401 - moved (FAA-65)
68
68
  from check_service_rules import _semver # noqa: E402,F401 - moved (FAA-65)
@@ -190,10 +190,10 @@ class Probe:
190
190
  except (OSError, ssl.SSLError) as exc:
191
191
  self.add("well-known.answers", "FAIL", "no TLS answer on %s: %s" % (self.d.get("origin"), exc))
192
192
  return
193
- self.add("well-known.requires-token", "PASS" if status == 401 and not body else "FAIL",
194
- "HTTP %d without a token, %d byte(s)" % (status, len(body)))
193
+ no_token = (status, body) # judged once the document names the identity values (DEC-0109)
195
194
  self.read_token()
196
195
  if not self.token:
196
+ self.add_no_token(no_token, None)
197
197
  self.add("well-known.answers", "NOT_RUN", "no readable token to ask with")
198
198
  return
199
199
  protected = self.auth_headers()
@@ -204,16 +204,24 @@ class Probe:
204
204
  status, headers, body = self.request("GET", "/.well-known/fabric-service", protected)
205
205
  timings.append((time.perf_counter() - started) * 1000)
206
206
  except OSError as exc:
207
+ if self.remote:
208
+ self.add_no_token(no_token, None)
207
209
  self.add("well-known.answers", "FAIL", "no answer on %s: %s" % (self.d.get("origin"), exc))
208
210
  return
209
211
  if status != 200:
212
+ if self.remote:
213
+ self.add_no_token(no_token, None)
210
214
  self.add("well-known.answers", "FAIL", "HTTP %d" % status)
211
215
  return
212
216
  try:
213
217
  self.wk = json.loads(body)
214
218
  except ValueError:
219
+ if self.remote:
220
+ self.add_no_token(no_token, None)
215
221
  self.add("well-known.answers", "FAIL", "body is not JSON")
216
222
  return
223
+ if self.remote:
224
+ self.add_no_token(no_token, self.wk)
217
225
  self.add("well-known.answers", "PASS", "HTTP 200")
218
226
  median = sorted(timings)[1]
219
227
  if self.remote:
@@ -257,6 +265,25 @@ class Probe:
257
265
  self.add("well-known.ready-means-healthy", "FAIL" if ready_bad else "PASS",
258
266
  "status %s, %d degraded" % (wk.get("status"), len(wk.get("degraded") or [])))
259
267
 
268
+ def add_no_token(self, answer: Tuple[int, bytes], wk: Any) -> None:
269
+ """`well-known.requires-token` (DEC-0109): the no-token answer, against the identity values the descriptor
270
+ names and, when the document was read, its build and pid."""
271
+ status, body = answer
272
+ sid, instance = str(self.d.get("id") or ""), str(self.d.get("instance") or "default")
273
+ values = [sid, "%s.%s" % (sid, instance)]
274
+ if isinstance(wk, dict):
275
+ svc = wk.get("service") if isinstance(wk.get("service"), dict) else {}
276
+ build = svc.get("build") if isinstance(svc.get("build"), dict) else {}
277
+ process = wk.get("process") if isinstance(wk.get("process"), dict) else {}
278
+ if svc.get("id"):
279
+ values.append(str(svc["id"]))
280
+ values.append("%s.%s" % (svc["id"], svc.get("instance") or instance))
281
+ values += [str(build[k]) for k in ("commit", "digest") if build.get(k)]
282
+ if isinstance(process.get("pid"), int) and not isinstance(process.get("pid"), bool):
283
+ values.append(str(process["pid"]))
284
+ verdict, evidence = no_token_verdict(status, body, list(dict.fromkeys(values)), self.host_name)
285
+ self.add("well-known.requires-token", verdict, evidence)
286
+
260
287
  # network ------------------------------------------------------------------
261
288
  def network_rules(self) -> None:
262
289
  for rule, headers in (
@@ -88,6 +88,48 @@ def guard_verdict(rule: str, remote: bool, status: int, body: bytes) -> Tuple[st
88
88
  return "FAIL", "HTTP %d%s" % (status, ", and the body is the well-known document" if disclosed else "")
89
89
 
90
90
 
91
+ def _carries_document(value: Any, depth: int = 0) -> bool:
92
+ """Whether a parsed body is, or holds anywhere inside it, an object that reads as the well-known document: its
93
+ `protocol` is the extension's, or it carries `service` or `process` (D3 of FAA-109)."""
94
+ if depth > 64:
95
+ return True # nothing honest nests that deep; refuse rather than recurse without bound
96
+ if isinstance(value, dict):
97
+ if value.get("protocol") == fs.PROTOCOL or "service" in value or "process" in value:
98
+ return True
99
+ return any(_carries_document(v, depth + 1) for v in value.values())
100
+ if isinstance(value, list):
101
+ return any(_carries_document(v, depth + 1) for v in value)
102
+ return False
103
+
104
+
105
+ def no_token_verdict(status: int, body: bytes, identities: List[str], host: str = "") -> Tuple[str, str]:
106
+ """DEC-0109 (`well-known.requires-token` on a remote placement): the no-token answer is `401` and carries none of
107
+ the service's data. An empty body passes; so does an identity proxy's page in front of the service, as long as it
108
+ neither parses as the well-known document nor names one of the service's own identity values (its id, its
109
+ `id.instance`, its build or its pid, as whole words). The service's own host name is taken out first: a sign-in page
110
+ names the address it guards, and the address is not the document's data. A value shorter than four characters
111
+ names nothing on its own (a platform's pid is often 1) and is not looked for."""
112
+ if status != 401:
113
+ return "FAIL", "HTTP %d without a token" % status
114
+ if not body:
115
+ return "PASS", "HTTP 401 without a token, an empty body"
116
+ text = body.decode("utf-8", "replace")
117
+ try:
118
+ doc = json.loads(text)
119
+ except RecursionError:
120
+ return "FAIL", "HTTP 401, and the body nests deeper than any page does"
121
+ except ValueError:
122
+ doc = None
123
+ if _carries_document(doc):
124
+ return "FAIL", "HTTP 401, and the body carries the well-known document"
125
+ if host:
126
+ text = re.sub(re.escape(host), " ", text, flags=re.IGNORECASE)
127
+ for value in identities:
128
+ if len(value) >= 4 and re.search(r"(?<![A-Za-z0-9_.-])%s(?![A-Za-z0-9_-])" % re.escape(value), text):
129
+ return "FAIL", "HTTP 401, and the %d-byte body names the service's identity value %r" % (len(body), value)
130
+ return "PASS", "HTTP 401 without a token, a %d-byte page that is not the service's (an identity proxy's, DEC-0109)" % len(body)
131
+
132
+
91
133
  def board_responder_verdict(wk: Any, kit: Optional[str] = None) -> Tuple[str, str]:
92
134
  """board.responder: NOT_RUN when the well-known document declares no fabric-project-comms revision; FAIL when it
93
135
  declares one this kit does not read or the kit's fixture conversation finds a problem; PASS otherwise. `kit` is the
@@ -6,6 +6,8 @@
6
6
  // it has checked the revision against estate-record.schema.json (the verifier does not check the schema);
7
7
  // - estateRecordRules(kind, value) — FAC-SEM-072…076, with FAC-SEM-134 on each machine's profile (DEC-0106);
8
8
  // - machineProfile(record, machineId) — a machine's profile (DEC-0106): `full`, `member`, `view`, or undefined;
9
+ // - deltaAnchorOf(record) — the revocation delta's head a revision vouches for (DEC-0107), or undefined; a revision
10
+ // never moves it back (FAC-SEM-137, in the revision rules and the verdict);
9
11
  // - hubKeyCertified(record, kid, x, at) — DL-14: a delegation verifier that reads the estate record trusts a token's
10
12
  // `kid` only while a current revision certifies it for a machine (fabric-delegation.mjs calls it).
11
13
  //
@@ -228,7 +230,7 @@ export function verifyEstateRecord(record, trust) {
228
230
  if (at < issuedAt - CLOCK_SKEW_SECONDS * 1000) return { ok: false, problem: 'not-yet-valid' };
229
231
  if (at > validUntil) return { ok: false, problem: 'expired' };
230
232
  // With a parent, what the revision drops it revokes (FAC-SEM-114, DEC-0084): a removed machine, a replaced key.
231
- const dropped = isObject(trust.parent) ? droppedFindings(trust.parent, record) : [];
233
+ const dropped = isObject(trust.parent) ? [...droppedFindings(trust.parent, record), ...anchorFindings(trust.parent, record)] : [];
232
234
  const codes = [...new Set([...recordFindings(record), ...dropped].map((finding) => finding.code))];
233
235
  if (codes.length > 0) return { ok: false, problem: 'rule-violation', codes };
234
236
  return { ok: true, digest, revision: record.revision, signedBy: credential.credentialId, signCount: assertion.signCount };
@@ -460,7 +462,7 @@ function secretReferences(value, flag) {
460
462
  if (!placed.has(keyOf(secret.agentRef, contextOf(secret)))) flag(`${at}/agentRef`, "a secret goes only where a placed package declares it (v4 Path 2): this agent is not placed on this machine in the secret's context");
461
463
  if (!machineIds.has(secret.from)) flag(`${at}/from`, 'the slot is filled from a machine of this estate');
462
464
  // An envelope names no context (DEC-0082), so an organization's instance fills its slot on its own machine.
463
- else if (contextOf(secret) !== PERSONAL_CONTEXT && secret.from !== machine.id) flag(`${at}/from`, "an organization's instance fills its slot from its own machine's vault, never by an envelope from another machine");
465
+ else if (contextOf(secret) !== PERSONAL_CONTEXT && secret.from !== machine.id) flag(`${at}/from`, "an organization's instance fills its slot from its own machine's vault, never by a secret envelope from another machine");
464
466
  const key = keyOf(secret.agentRef, secret.slot, contextOf(secret));
465
467
  if (slots.has(key)) flag(`${at}/slot`, `slot ${show(secret.slot)} is placed twice for one agent in one context on one machine`);
466
468
  slots.add(key);
@@ -642,6 +644,29 @@ function installation(value) {
642
644
  return findings;
643
645
  }
644
646
 
647
+ /** The anchor a record vouches for (DEC-0107), or undefined when it names none or its shape is not the schema's. */
648
+ export function deltaAnchorOf(record) {
649
+ if (!isObject(record) || !isObject(record.deltaAnchor)) return undefined;
650
+ const { sequence, sha256 } = record.deltaAnchor;
651
+ return Number.isSafeInteger(sequence) && sequence >= 1 && isString(sha256) && /^[a-f0-9]{64}$/.test(sha256) ? { sequence, sha256 } : undefined;
652
+ }
653
+
654
+ /**
655
+ * FAC-SEM-137 across two revisions (DEC-0107): the delta anchor never moves back. A revision whose parent anchors the
656
+ * delta anchors it too, at the parent's sequence or later, with the parent's hash at the same sequence.
657
+ */
658
+ function anchorFindings(parent, next) {
659
+ const findings = [];
660
+ const flag = flagger(findings, 'FAC-SEM-137');
661
+ const before = deltaAnchorOf(parent);
662
+ if (before === undefined) return findings;
663
+ const after = deltaAnchorOf(next);
664
+ if (after === undefined) flag('/next/deltaAnchor', `the parent anchors the delta at ${before.sequence}: a revision keeps an anchor once one is set`);
665
+ else if (after.sequence < before.sequence) flag('/next/deltaAnchor/sequence', `the anchor moves back from ${before.sequence} to ${after.sequence}`);
666
+ else if (after.sequence === before.sequence && after.sha256 !== before.sha256) flag('/next/deltaAnchor/sha256', `another hash at sequence ${after.sequence}: two delta histories`);
667
+ return findings;
668
+ }
669
+
645
670
  /**
646
671
  * FAC-SEM-114 across two revisions (DEC-0084): what a revision drops, it revokes in the same revision. A removed machine
647
672
  * as `machine <id>` and its hub key as `hub-key <kid>`; a replaced or dropped device key of a kept machine as `device-key`
@@ -680,7 +705,11 @@ function droppedFindings(parent, next) {
680
705
  export function estateRecordRules(kind, value) {
681
706
  const doc = isObject(value) ? value : {};
682
707
  if (kind === 'estate-record') return recordFindings(doc);
683
- if (kind === 'estate-record-revision') return [...revisionChain(doc), ...droppedFindings(isObject(doc.parent) ? doc.parent : {}, isObject(doc.next) ? doc.next : {})];
708
+ if (kind === 'estate-record-revision') {
709
+ const parent = isObject(doc.parent) ? doc.parent : {};
710
+ const next = isObject(doc.next) ? doc.next : {};
711
+ return [...revisionChain(doc), ...droppedFindings(parent, next), ...anchorFindings(parent, next)];
712
+ }
684
713
  if (kind === 'estate-placement') return installation(doc);
685
714
  return undefined;
686
715
  }
@@ -7,8 +7,8 @@ import { credentialName, secretArgument, secretUrl, secretValue } from './fabric
7
7
 
8
8
  /** Each contract protocol's published revisions, oldest first (the contract's `PROTOCOL_REVISIONS`); a test holds it equal to the contract's. */
9
9
  export const PROTOCOL_REVISIONS = new Map(Object.entries({
10
- 'access-log': ['1'], approval: ['1'], 'activity-summary': ['1'], 'agent-directory': ['1'], 'coding-agents': ['1'], 'enroll-token': ['1'], entitlement: ['1'], 'entitlement-key-set': ['1'], 'estate-record': ['1'], 'execution-packet': ['1'],
11
- 'fabric-activity': ['0.1'], 'fabric-agent-call': ['1'], 'fabric-connections': ['0.1'], 'fabric-load': ['0.1'], 'fabric-dashboard': ['0.1'], 'fabric-delegation': ['0.1'], 'fabric-device': ['0.1'],
10
+ 'access-log': ['1'], approval: ['1'], 'activity-summary': ['1'], 'agent-directory': ['1'], 'call-record': ['1'], 'coding-agents': ['1'], 'credential-decision': ['1'], 'credential-request': ['1'], 'credential-revocation': ['1'], 'enroll-token': ['1'], entitlement: ['1'], 'entitlement-key-set': ['1'], 'estate-record': ['1'], 'execution-packet': ['1'],
11
+ 'fabric-activity': ['0.1'], 'fabric-agent-call': ['1'], 'fabric-connections': ['0.1'], 'fabric-credential-envelope': ['1'], 'fabric-load': ['0.1'], 'fabric-dashboard': ['0.1'], 'fabric-delegation': ['0.1'], 'fabric-device': ['0.1'],
12
12
  'fabric-interop': ['0.1', '0.2'], 'fabric-local-channel': ['0.1'], 'fabric-local-runner': ['0.1'], 'fabric-operator-channel': ['0.1'],
13
13
  'fabric-package': ['1'], 'fabric-project-comms': ['0.1'], 'fabric-proposal': ['1'], 'fabric-provider': ['0.1'], 'fabric-relay': ['0.1'],
14
14
  'fabric-secret-envelope': ['1'], 'fabric-service': ['0.1'], 'fabric-settings-backup': ['1'], heartbeat: ['1'], 'learning-note': ['1'],
@@ -70,6 +70,7 @@ export {
70
70
  attributionSettingFindings,
71
71
  attributionHeaders,
72
72
  usageReport,
73
+ usageSpend,
73
74
  LIMIT_SCOPES,
74
75
  PER_ORDER_KINDS,
75
76
  isTimestamp,
@@ -310,21 +310,65 @@ export function usageReport(receipts, { id, instance = 'default', now = Date.now
310
310
  return report;
311
311
  }
312
312
 
313
- /** Spend in a rolling window, from the receipts' own times; null when every call in it is unpriced. */
313
+ /**
314
+ * What a host shows for usage rows (DEC-0110, the contract's usageSpend): money spent apart from a subscription's
315
+ * estimate. `rows` are `days[].byModel[]` rows. Money is the `costUsd` of every row whose `route` is `key`,
316
+ * `platform-metered` or absent; a `subscription` row's `costUsd` is an estimate at list price, summed apart. A window
317
+ * whose calls were all on a subscription spent 0; money is null only when it has calls off a subscription and none of
318
+ * them is priced, and a lower bound while some are priced and some are not. An unknown `route`, or counts or a cost the
319
+ * schema refuses, answers undefined. It never throws on a JSON value.
320
+ */
321
+ export function usageSpend(rows) {
322
+ if (!Array.isArray(rows)) return undefined;
323
+ const known = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0;
324
+ const money = [], estimate = [];
325
+ let calls = 0, subscriptionCalls = 0;
326
+ const paid = { calls: 0, priced: 0, unpriced: false }, sub = { calls: 0, priced: 0, unpriced: false };
327
+ for (const row of rows) {
328
+ if (!isPlainObject(row)) return undefined;
329
+ const { route } = row;
330
+ if (route !== undefined && !(typeof route === 'string' && USAGE_ROUTES.includes(route))) return undefined;
331
+ const rowCalls = row.calls, unpriced = row.unpricedCalls === undefined ? 0 : row.unpricedCalls, rowCost = row.costUsd;
332
+ if (!Number.isSafeInteger(rowCalls) || rowCalls < 0 || !Number.isSafeInteger(unpriced) || unpriced < 0 || unpriced > rowCalls) return undefined;
333
+ if (rowCost !== null && !known(rowCost)) return undefined;
334
+ if (rowCalls > 0 && (rowCost === null) !== (unpriced === rowCalls)) return undefined; // FAC-SEM-025's own pairing
335
+ calls += rowCalls;
336
+ const side = route === 'subscription' ? sub : paid;
337
+ if (route === 'subscription') subscriptionCalls += rowCalls;
338
+ side.calls += rowCalls;
339
+ side.priced += rowCalls - unpriced;
340
+ if (unpriced > 0) side.unpriced = true;
341
+ if (known(rowCost)) (route === 'subscription' ? estimate : money).push(rowCost);
342
+ }
343
+ const total = (side, costs) => (side.calls > 0 && side.priced === 0 ? null : costs.sort((a, b) => a - b).reduce((n, x) => n + x, 0));
344
+ return {
345
+ calls,
346
+ moneyUsd: total(paid, money),
347
+ moneyLowerBound: paid.priced > 0 && paid.unpriced,
348
+ estimateUsd: total(sub, estimate),
349
+ estimateLowerBound: sub.priced > 0 && sub.unpriced,
350
+ subscriptionCalls,
351
+ };
352
+ }
353
+
354
+ /** CT-115: a limit counts money only, never a subscription's estimate (DEC-0110). */
355
+ function money(spend) {
356
+ return spend === undefined || spend.moneyUsd === null ? null : Math.round(spend.moneyUsd * 1e6) / 1e6;
357
+ }
358
+
359
+ /** Money spent in a rolling window, from the receipts' own times; null when no call off a subscription in it is priced. */
314
360
  function spentSince(receipts, start, end) {
315
361
  const iso = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
316
362
  const lo = iso(start), hi = iso(end);
317
363
  const inside = [...receipts].filter((r) => String(r.at ?? '') > lo && String(r.at ?? '') <= hi);
318
- if (inside.length && inside.every((r) => r.costUsd === null || r.costUsd === undefined)) return null;
319
- return Math.round(inside.reduce((n, r) => n + (r.costUsd ?? 0), 0) * 1e6) / 1e6;
364
+ const priced = (r) => r.costUsd !== null && r.costUsd !== undefined;
365
+ return money(usageSpend(inside.map((r) => ({ ...(r.route ? { route: r.route } : {}), calls: 1, unpricedCalls: priced(r) ? 0 : 1, costUsd: priced(r) ? r.costUsd : null }))));
320
366
  }
321
367
 
322
- /** Spend in the current UTC day or month; null when every call in it is unpriced. */
368
+ /** Money spent in the current UTC day or month, over the window's model rows; null when no call off a subscription in it is priced. */
323
369
  function spentIn(outDays, period, last) {
324
370
  const prefix = period === 'day' ? last : last.slice(0, 7);
325
- const window = outDays.filter((d) => d.date.startsWith(prefix));
326
- const unknown = window.length > 0 && window.every((d) => d.costUsd === null);
327
- return unknown ? null : Math.round(window.reduce((n, d) => n + (d.costUsd ?? 0), 0) * 1e6) / 1e6;
371
+ return money(usageSpend(outDays.filter((d) => d.date.startsWith(prefix)).flatMap((d) => d.byModel)));
328
372
  }
329
373
 
330
374
  export const LIMIT_SCOPES = Object.freeze(['machine', 'project', 'pool', 'job']);
@@ -12,6 +12,8 @@ at the pin, ported answer for answer and held to the contract's own vectors (`te
12
12
  - ``estate_record_rules(kind, value)`` — FAC-SEM-072…076 for `estate-record`, `estate-record-revision` and
13
13
  `estate-placement`, with FAC-SEM-134 on each machine's profile (DEC-0106);
14
14
  - ``machine_profile(record, machine_id)`` — a machine's profile (DEC-0106): `full`, `member`, `view`, or None;
15
+ - ``delta_anchor_of(record)`` — the revocation delta's head a revision vouches for (DEC-0107), or None; a revision
16
+ never moves it back (FAC-SEM-137, in the revision rules and the verdict);
15
17
  - ``hub_key_certified(record, kid, x, at)`` — DL-14: a delegation verifier that reads the estate record trusts a token's
16
18
  `kid` only while a current revision certifies it for a machine (``fabric_delegation`` calls it).
17
19
 
@@ -57,6 +59,7 @@ from fabric_jsvalue import ( # noqa: E402 - the core (FAA-65)
57
59
  objects as _list,
58
60
  safe_integer as _safe_integer,
59
61
  same as _same,
62
+ set_has as _set_has,
60
63
  show as _show,
61
64
  )
62
65
  from fabric_time import ( # noqa: E402 - the core (FAA-65)
@@ -355,7 +358,7 @@ def verify_estate_record(record: Any, *, at: Any, parent: Any = _MISSING, author
355
358
  return {"ok": False, "problem": "expired"}
356
359
  codes: List[str] = []
357
360
  # With a parent, what the revision drops it revokes (FAC-SEM-114, DEC-0084): a removed machine, a replaced key.
358
- dropped = _dropped_findings(parent, record) if isinstance(parent, dict) else []
361
+ dropped = _dropped_findings(parent, record) + _anchor_findings(parent, record) if isinstance(parent, dict) else []
359
362
  for finding in _record_findings(record) + dropped:
360
363
  if finding["code"] not in codes:
361
364
  codes.append(finding["code"])
@@ -639,7 +642,7 @@ def _secret_references(value: Dict[str, Any], flag) -> None:
639
642
  flag("%s/from" % at, "the slot is filled from a machine of this estate")
640
643
  elif not _same(_context_of(secret), PERSONAL_CONTEXT) and not _same(_get(secret, "from"), _get(machine, "id")):
641
644
  # An envelope names no context (DEC-0082): an organization's instance fills its slot on its own machine.
642
- flag("%s/from" % at, "an organization's instance fills its slot from its own machine's vault, never by an envelope from another machine")
645
+ flag("%s/from" % at, "an organization's instance fills its slot from its own machine's vault, never by a secret envelope from another machine")
643
646
  key = _key_of(_get(secret, "agentRef"), _get(secret, "slot"), _context_of(secret))
644
647
  if key in slots:
645
648
  flag("%s/slot" % at, "slot %s is placed twice for one agent in one context on one machine" % _show(_get(secret, "slot")))
@@ -690,7 +693,7 @@ def _machine_profiles(value: Dict[str, Any], flag) -> None:
690
693
  flag("/machines/%d/secrets" % m, "a view machine holds none of the estate's secrets")
691
694
  for machine, m in _indexed(_get(value, "machines")):
692
695
  for secret, s in _indexed(_get(machine, "secrets")):
693
- if _contains(views, _get(secret, "from")):
696
+ if _set_has(views, _get(secret, "from")): # the contract's Set: NaN matches NaN
694
697
  flag("/machines/%d/secrets/%d/from" % (m, s), "machine %s is a view machine: it is the source of no secret" % _show(_get(secret, "from")))
695
698
 
696
699
 
@@ -781,6 +784,38 @@ def _placement_grants(value: Dict[str, Any], flag) -> None:
781
784
  flag("/machines/%d/packages/%d%s" % (m, p, suffix), message)
782
785
 
783
786
 
787
+ _ANCHOR_HASH = re.compile(r"[a-f0-9]{64}")
788
+
789
+
790
+ def delta_anchor_of(record: Any) -> Optional[Dict[str, Any]]:
791
+ """The anchor a record vouches for (DEC-0107), or None when it names none or its shape is not the schema's. The
792
+ contract's ``deltaAnchorOf``."""
793
+ if not isinstance(record, dict) or not isinstance(_get(record, "deltaAnchor"), dict):
794
+ return None
795
+ sequence, sha = _get(record["deltaAnchor"], "sequence"), _get(record["deltaAnchor"], "sha256")
796
+ if _safe_integer(sequence) and sequence >= 1 and isinstance(sha, str) and _ANCHOR_HASH.fullmatch(sha):
797
+ return {"sequence": sequence, "sha256": sha}
798
+ return None
799
+
800
+
801
+ def _anchor_findings(parent: Dict[str, Any], nxt: Dict[str, Any]) -> List[Dict[str, str]]:
802
+ """FAC-SEM-137 across two revisions (DEC-0107): the delta anchor never moves back. A revision whose parent anchors
803
+ the delta anchors it too, at the parent's sequence or later, with the parent's hash at the same sequence."""
804
+ findings: List[Dict[str, str]] = []
805
+ flag = _finding(findings, "FAC-SEM-137")
806
+ before = delta_anchor_of(parent)
807
+ if before is None:
808
+ return findings
809
+ after = delta_anchor_of(nxt)
810
+ if after is None:
811
+ flag("/next/deltaAnchor", "the parent anchors the delta at %s: a revision keeps an anchor once one is set" % _js_string(before["sequence"]))
812
+ elif after["sequence"] < before["sequence"]:
813
+ flag("/next/deltaAnchor/sequence", "the anchor moves back from %s to %s" % (_js_string(before["sequence"]), _js_string(after["sequence"])))
814
+ elif after["sequence"] == before["sequence"] and after["sha256"] != before["sha256"]:
815
+ flag("/next/deltaAnchor/sha256", "another hash at sequence %s: two delta histories" % _js_string(after["sequence"]))
816
+ return findings
817
+
818
+
784
819
  def _dropped_findings(parent: Dict[str, Any], nxt: Dict[str, Any]) -> List[Dict[str, str]]:
785
820
  """FAC-SEM-114 across two revisions (DEC-0084): what a revision drops, it revokes in the same revision. A removed
786
821
  machine as ``machine <id>`` and its hub key as ``hub-key <kid>``; a replaced or dropped device key of a kept machine
@@ -894,7 +929,7 @@ def estate_record_rules(kind: str, value: Any) -> Optional[List[Dict[str, str]]]
894
929
  if kind == "estate-record-revision":
895
930
  parent = value["parent"] if isinstance(_get(value, "parent"), dict) else {}
896
931
  nxt = value["next"] if isinstance(_get(value, "next"), dict) else {}
897
- return _revision_chain(value) + _dropped_findings(parent, nxt)
932
+ return _revision_chain(value) + _dropped_findings(parent, nxt) + _anchor_findings(parent, nxt)
898
933
  if kind == "estate-placement":
899
934
  return _installation(value)
900
935
  return None
@@ -157,7 +157,8 @@ def double(value: Any) -> float:
157
157
 
158
158
 
159
159
  def same(a: Any, b: Any) -> bool:
160
- """JavaScript's === (and a Set's SameValueZero) on parsed JSON: an object equals only itself."""
160
+ """JavaScript's === on parsed JSON: an object equals only itself. A Set's SameValueZero differs only for NaN, which
161
+ no JSON value is: use ``set_has`` where the contract keeps a Set (FAA-107)."""
161
162
  if a is MISSING or b is MISSING:
162
163
  return a is b
163
164
  if isinstance(a, (dict, list)) or isinstance(b, (dict, list)):
@@ -173,6 +174,12 @@ def contains(items: List[Any], value: Any) -> bool:
173
174
  return any(same(item, value) for item in items)
174
175
 
175
176
 
177
+ def set_has(items: List[Any], value: Any) -> bool:
178
+ """A JavaScript Set's ``has`` over ``items``: SameValueZero, which is ``same`` except that NaN matches NaN."""
179
+ nan = isinstance(value, float) and math.isnan(value)
180
+ return any(same(item, value) or (nan and isinstance(item, float) and math.isnan(item)) for item in items)
181
+
182
+
176
183
  #: The characters JavaScript's StringToNumber trims: WhiteSpace and LineTerminator (ECMA-262 §12.2, §12.3).
177
184
  _JS_SPACE = "\t\n\v\f\r              

   "
178
185
 
@@ -13,9 +13,9 @@ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from
13
13
  #: Each contract protocol's published revisions, oldest first (the contract's `PROTOCOL_REVISIONS`, DEC-0067); a test
14
14
  #: holds it equal to the contract's register at the pin.
15
15
  PROTOCOL_REVISIONS: Dict[str, Tuple[str, ...]] = {
16
- "access-log": ("1",), "approval": ("1",), "activity-summary": ("1",), "agent-directory": ("1",), "coding-agents": ("1",), "enroll-token": ("1",), "entitlement": ("1",),
16
+ "access-log": ("1",), "approval": ("1",), "activity-summary": ("1",), "agent-directory": ("1",), "call-record": ("1",), "coding-agents": ("1",), "credential-decision": ("1",), "credential-request": ("1",), "credential-revocation": ("1",), "enroll-token": ("1",), "entitlement": ("1",),
17
17
  "entitlement-key-set": ("1",), "estate-record": ("1",), "execution-packet": ("1",),
18
- "fabric-activity": ("0.1",), "fabric-agent-call": ("1",), "fabric-connections": ("0.1",), "fabric-load": ("0.1",), "fabric-dashboard": ("0.1",), "fabric-delegation": ("0.1",), "fabric-device": ("0.1",),
18
+ "fabric-activity": ("0.1",), "fabric-agent-call": ("1",), "fabric-connections": ("0.1",), "fabric-credential-envelope": ("1",), "fabric-load": ("0.1",), "fabric-dashboard": ("0.1",), "fabric-delegation": ("0.1",), "fabric-device": ("0.1",),
19
19
  "fabric-interop": ("0.1", "0.2"), "fabric-local-channel": ("0.1",), "fabric-local-runner": ("0.1",),
20
20
  "fabric-operator-channel": ("0.1",), "fabric-package": ("1",), "fabric-project-comms": ("0.1",), "fabric-proposal": ("1",),
21
21
  "fabric-provider": ("0.1",), "fabric-relay": ("0.1",), "fabric-secret-envelope": ("1",), "fabric-service": ("0.1",),
@@ -135,6 +135,7 @@ from fabric_usage import ( # noqa: F401
135
135
  _is_openrouter,
136
136
  attribution_headers,
137
137
  usage_report,
138
+ usage_spend,
138
139
  _spent_since,
139
140
  _spent_in,
140
141
  LIMIT_SCOPES,
@@ -494,21 +494,95 @@ def usage_report(receipts: Iterable[Dict[str, Any]], *, service_id: str, instanc
494
494
  return report
495
495
 
496
496
 
497
+ def usage_spend(rows: Any) -> Optional[Dict[str, Any]]:
498
+ """What a host shows for usage rows (DEC-0110, the contract's ``usageSpend``): money spent apart from a
499
+ subscription's estimate. ``rows`` are ``days[].byModel[]`` rows. Money is the ``costUsd`` of every row whose
500
+ ``route`` is ``key``, ``platform-metered`` or absent; a ``subscription`` row's ``costUsd`` is an estimate at list
501
+ price, summed apart. A window whose calls were all on a subscription spent 0; money is None only when it has calls
502
+ off a subscription and none of them is priced, and a lower bound while some are priced and some are not. An unknown
503
+ ``route``, or counts or a cost the schema refuses, answers None. It never raises on a JSON value."""
504
+ if not isinstance(rows, list):
505
+ return None
506
+ money: List[float] = []
507
+ estimate: List[float] = []
508
+ calls = subscription_calls = 0
509
+ paid = {"calls": 0, "priced": 0, "unpriced": False}
510
+ sub = {"calls": 0, "priced": 0, "unpriced": False}
511
+ for row in rows:
512
+ if not isinstance(row, dict):
513
+ return None
514
+ route = row.get("route", _jsv.MISSING)
515
+ if route is not _jsv.MISSING and not (isinstance(route, str) and route in USAGE_ROUTES):
516
+ return None
517
+ row_calls, cost = row.get("calls", _jsv.MISSING), row.get("costUsd", _jsv.MISSING)
518
+ unpriced = row.get("unpricedCalls", 0)
519
+ if not _whole(row_calls) or not _whole(unpriced) or unpriced > row_calls:
520
+ return None
521
+ if cost is not None and not _known_cost(cost):
522
+ return None
523
+ if row_calls > 0 and (cost is None) != (unpriced == row_calls): # FAC-SEM-025's own pairing
524
+ return None
525
+ calls += int(row_calls)
526
+ side = sub if route == "subscription" else paid
527
+ if route == "subscription":
528
+ subscription_calls += int(row_calls)
529
+ side["calls"] += int(row_calls)
530
+ side["priced"] += int(row_calls) - int(unpriced)
531
+ if unpriced > 0:
532
+ side["unpriced"] = True
533
+ if _known_cost(cost):
534
+ (estimate if route == "subscription" else money).append(_jsv.double(cost))
535
+
536
+ def total(side: Dict[str, Any], costs: List[float]) -> Optional[float]:
537
+ if side["calls"] > 0 and side["priced"] == 0:
538
+ return None
539
+ n: Any = 0
540
+ for x in sorted(costs): # in a fixed order, one addition at a time, as JavaScript's reduce adds
541
+ n = n + x
542
+ return n
543
+
544
+ return {"calls": calls, "moneyUsd": total(paid, money), "moneyLowerBound": paid["priced"] > 0 and paid["unpriced"],
545
+ "estimateUsd": total(sub, estimate), "estimateLowerBound": sub["priced"] > 0 and sub["unpriced"],
546
+ "subscriptionCalls": subscription_calls}
547
+
548
+
549
+ def _whole(value: Any) -> bool:
550
+ """Number.isSafeInteger(value) and not negative."""
551
+ return _jsv.safe_integer(value) and value >= 0
552
+
553
+
554
+ def _known_cost(value: Any) -> bool:
555
+ """A finite cost of at least 0, the number read as JavaScript holds it (an integer past the double range is
556
+ Infinity, never an OverflowError)."""
557
+ return _number(value) and math.isfinite(_jsv.double(value)) and value >= 0
558
+
559
+
560
+ def _money(spend: Optional[Dict[str, Any]]) -> Optional[float]:
561
+ """CT-115: a limit counts money only, never a subscription's estimate (DEC-0110)."""
562
+ if spend is None or spend["moneyUsd"] is None:
563
+ return None
564
+ return round(float(spend["moneyUsd"]), 6)
565
+
566
+
497
567
  def _spent_since(receipts: List[Dict[str, Any]], start: _dt.datetime, end: _dt.datetime) -> Optional[float]:
498
- """Spend in a rolling window, from the receipts' own times; None when every call in it is unpriced."""
568
+ """Money spent in a rolling window, from the receipts' own times; None when no call off a subscription in it is
569
+ priced. A subscription's calls cost no money (DEC-0110, CT-115)."""
499
570
  lo, hi = start.strftime("%Y-%m-%dT%H:%M:%SZ"), end.strftime("%Y-%m-%dT%H:%M:%SZ")
500
- inside = [r for r in receipts if lo < str(r.get("at", "")) <= hi]
501
- if inside and all(r.get("costUsd") is None for r in inside):
502
- return None
503
- return round(sum(r.get("costUsd") or 0.0 for r in inside), 6)
571
+ rows = []
572
+ for r in receipts:
573
+ if lo < str(r.get("at", "")) <= hi:
574
+ row = {"calls": 1, "unpricedCalls": 1 if r.get("costUsd") is None else 0, "costUsd": r.get("costUsd")}
575
+ if r.get("route"):
576
+ row["route"] = r["route"]
577
+ rows.append(row)
578
+ return _money(usage_spend(rows))
504
579
 
505
580
 
506
581
  def _spent_in(out_days: List[Dict[str, Any]], period: str, last: str) -> Optional[float]:
507
- """Spend in the current UTC day or month; None when every call in it is unpriced."""
582
+ """Money spent in the current UTC day or month, over the window's model rows; None when no call off a subscription
583
+ in it is priced. A subscription's calls cost no money (DEC-0110, CT-115)."""
508
584
  prefix = last if period == "day" else last[:7]
509
- window = [d for d in out_days if d["date"].startswith(prefix)]
510
- unknown = bool(window) and all(d["costUsd"] is None for d in window)
511
- return None if unknown else round(sum(d["costUsd"] or 0.0 for d in window), 6)
585
+ return _money(usage_spend([m for d in out_days if d["date"].startswith(prefix) for m in d["byModel"]]))
512
586
 
513
587
 
514
588
  LIMIT_SCOPES = ("machine", "project", "pool", "job")
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fabric-service-kit",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
4
4
  "about": "The service kit's modules in dependency order: a module imports only modules listed before it. Written by fabric_kit.py build; edit the order and layers by hand, never the digests.",
5
5
  "python": [
6
6
  {
@@ -8,7 +8,7 @@
8
8
  "file": "fabric_jsvalue.py",
9
9
  "layer": "L0",
10
10
  "imports": [],
11
- "sha256": "0cd4eab0a5d6ae35d097f7c59a08dc29173cb61c561c49e5291a8dad15d36779"
11
+ "sha256": "ff1bbfae999fb5683e59e31189143f1c85fa9d7780040b5a6952e060a143fc18"
12
12
  },
13
13
  {
14
14
  "module": "fabric_time",
@@ -77,7 +77,7 @@
77
77
  "fabric_secrets",
78
78
  "fabric_time"
79
79
  ],
80
- "sha256": "a7400a3b2498ea2b6e3a845ed438580f2bf3c4ce58854ba4ad22d7cdf6f846bc"
80
+ "sha256": "f493cd424dae6380d1caad91ffdca2764e51a49a6b1051a77731e3d8755277cd"
81
81
  },
82
82
  {
83
83
  "module": "fabric_delegation",
@@ -150,7 +150,7 @@
150
150
  "imports": [
151
151
  "fabric_secrets"
152
152
  ],
153
- "sha256": "89babdb43468a90e4aa9df388a82dd498ae1e0ac5445ac4a1b9c225d8bc61f67"
153
+ "sha256": "28e693282cddff8e4396ae39ce6fa935e875e8ddac7375bea2c189dd6fb04970"
154
154
  },
155
155
  {
156
156
  "module": "fabric_connections",
@@ -184,7 +184,7 @@
184
184
  "fabric_paths",
185
185
  "fabric_time"
186
186
  ],
187
- "sha256": "ce685cb9fdfd62ea75fff8b1b2be12a2ba758da783e4bfb4dcebe47bcbeb832b"
187
+ "sha256": "70e984ec3ff9f4ee31539df8d503f01ef8e6dd870e65e9546c0a2cb4aa040a88"
188
188
  },
189
189
  {
190
190
  "module": "fabric_backups",
@@ -277,7 +277,7 @@
277
277
  "fabric_time",
278
278
  "fabric_usage"
279
279
  ],
280
- "sha256": "d411433ce6ff3e81189b10b2c1a8462335a97cd99574ab0ee7d1c28cf48e1694"
280
+ "sha256": "93368b68c4375dd43997e3b61b82d96743870d5481335461dc0f2d7af7ff3a67"
281
281
  },
282
282
  {
283
283
  "module": "fabric_profiles",
@@ -473,7 +473,7 @@
473
473
  "fabric-secrets",
474
474
  "fabric-time"
475
475
  ],
476
- "sha256": "c4784b0f73d06baaa5f8d104eb33ec21bf5433abfbaed046f24f1d07cf78c782"
476
+ "sha256": "b6610aaad9d00d8a7f42955627fb81725950841455bcedfc050abb0e079f0f93"
477
477
  },
478
478
  {
479
479
  "module": "fabric-delegation",
@@ -544,7 +544,7 @@
544
544
  "imports": [
545
545
  "fabric-secrets"
546
546
  ],
547
- "sha256": "b1fcf14ef1e4f2215111d9fdc89ff5bda3ce2a49aefcd2788feb5a1c12463cbf"
547
+ "sha256": "dc039ec3612d0090c9ba6a3a949b0b7df54712bead300dcb6cc8b9bc305cd183"
548
548
  },
549
549
  {
550
550
  "module": "fabric-connections",
@@ -578,7 +578,7 @@
578
578
  "fabric-paths",
579
579
  "fabric-time"
580
580
  ],
581
- "sha256": "e413569620bbc8221bc59e24b27e22d1483825ed18457801ed58d54a57de8114"
581
+ "sha256": "1c4d18004639409624ac3516a02f2b60bbf55abdb612e9a96c37e83666eb87a7"
582
582
  },
583
583
  {
584
584
  "module": "fabric-backups",
@@ -671,7 +671,7 @@
671
671
  "fabric-supervisors",
672
672
  "fabric-usage"
673
673
  ],
674
- "sha256": "bc67550e52aa6aa9c6d2ed6cbae52418a3e836623e17164166699f953d8aafa8"
674
+ "sha256": "1b4639a10c530c1241d317c5e9cf3a10cd96e5d9af8b29fc1b1844de5c63bfec"
675
675
  },
676
676
  {
677
677
  "module": "fabric-profiles",
@@ -2495,6 +2495,158 @@
2495
2495
  "fabric-openrouter.mjs": "f8f0168691f968b7cd85f36aa98e2167e88cc44f1104ff514fd0c7699b339253",
2496
2496
  "fabric-package.schema.json": "2ba799f576fad0f608029223c4ccccd122152f1c5dbfa8ce8b31cd63ac92ad44",
2497
2497
  "share-registration.schema.json": "34b3997eeaf936ebc861342a23045634a7c18c19f01d7ed4198310d81989f5b0"
2498
+ },
2499
+ "0.47.1": {
2500
+ "fabric_jsvalue.py": "ff1bbfae999fb5683e59e31189143f1c85fa9d7780040b5a6952e060a143fc18",
2501
+ "fabric_time.py": "588f383da4ac6a243ea95b6ffc3a0aa99ca94245732dc4937734f190b7af4ae9",
2502
+ "fabric_identifiers.py": "0fd814d521ac3aa39907458265870753046a73f48af3d8581ab864b93b211607",
2503
+ "fabric_secrets.py": "68a0b5e9e90c468ec9ac67b2f0fd1848e08813d6ec1ec14a2a47337eb1a98911",
2504
+ "fabric_crypto.py": "9c34aee20dbe4d6742e4fdbaab68ceb934bbe7dbd3a904c0010824ace16cdeaa",
2505
+ "fabric_base.py": "f62bcc2ed9912660fa875d27a09f60867260cc4925f79873501fd9620be59611",
2506
+ "fabric_platform.py": "a9350dff55ef0fb3a23cdb4d0320a15868b15a8deb10d2eb74d72c58740c284b",
2507
+ "fabric_paths.py": "6c92af34292baddf7e10d3134d0a5b0bb23387741812a9f79c409cf1628bad23",
2508
+ "fabric_estate.py": "d242bc4bccda94f91bf50ba03620f8dc8ade0060bfd87c6f6d704bd0657d7a2b",
2509
+ "fabric_delegation.py": "6638a6039befe3b43f59018bab292e050931af21b544df1044cd5059572ded36",
2510
+ "fabric_share.py": "78506fe1a60829c525dbf6266a0aa0749a2164290ed94da8249a20b4e9be5605",
2511
+ "fabric_ceremonies.py": "a59e83cf4adb2669a9b98008e3d3a0cba991a35cbff97bc604fc82fe81cfd49f",
2512
+ "fabric_package_schema.py": "e623f0408d38e3343ed66222fb812907c6c52e1a286eb1c1176ab2e64640c9ad",
2513
+ "fabric_package.py": "ff533f1b3f92b4ea0f532fcec50b68c610d47549930eef8ad5247692a64dfb3c",
2514
+ "fabric_protocols.py": "89babdb43468a90e4aa9df388a82dd498ae1e0ac5445ac4a1b9c225d8bc61f67",
2515
+ "fabric_connections.py": "77d3ccf863c70e9209d21ef45c46a6db9c06237019bc20d54f8d3946873f3f11",
2516
+ "fabric_lock.py": "ba2399d2137c7a7791b7133b9db9cca36693f4eb7e30955b25a75ade814e95ba",
2517
+ "fabric_usage.py": "ce685cb9fdfd62ea75fff8b1b2be12a2ba758da783e4bfb4dcebe47bcbeb832b",
2518
+ "fabric_backups.py": "02dc5eb66ec956723d3ddba07b6dd15f6a9726d901f4d60edfd02eee57880ceb",
2519
+ "fabric_descriptor.py": "daf16f95b1810283b1386488e006e940a0cdfa5c3c8d09386ac33d29d58df78d",
2520
+ "fabric_auth.py": "55794587750f6c9f40935b74295d83d7096cb4b26a0b36ac292334cd87d8f853",
2521
+ "fabric_events.py": "0086886c2c29dbd5c9d152be9d375c5bdf4a29f89b8095baff8490c2bcd64e47",
2522
+ "fabric_logs.py": "86dc55bfafa2a57264722e2403510a249b95de0e4a722b2e4bc3948e3bd93da9",
2523
+ "fabric_supervisors.py": "a2dbf15aa1c4eb9de42996b413103fa4814afd2e4e66d1ab7315f30e06ab6841",
2524
+ "fabric_service.py": "d411433ce6ff3e81189b10b2c1a8462335a97cd99574ab0ee7d1c28cf48e1694",
2525
+ "fabric_profiles.py": "26e21ec29c016000f8ca8053b7b489184854a73be1b7fdc0a18d12990203db9b",
2526
+ "fabric_interop_rules.py": "b5f0ecb1791b4c50c5ac151f987f692ed7001bc74841a8a161125ec775145e09",
2527
+ "fabric_interop.py": "f92fa111328fe3811438f733d8f0eb5295e6941c774123a78e26318bdcb35fc3",
2528
+ "fabric_hub.py": "b9ac852e601b1d54cf61351677a628afc69faeed7d1fc74ebe541c1cf38e0c2a",
2529
+ "fabric_board_shapes.py": "0a0e8d3d40e33dfd8c457aedb85330b2ff25f8c49a33eb6cbd5a5cc8a5007f7e",
2530
+ "fabric_board_link.py": "b5bff994cb615c6a4bb0d88d020e3a59504166180e1fa1aea3be93224f2aeccb",
2531
+ "fabric_board_session.py": "2271834c36c23f6bfa50395cd147e6af99f294ef09325fd15f0759ca8e18d941",
2532
+ "fabric_board_fixture.py": "69761ce205ec640762ec9b591c6903e5a87f844e8d423b041681924027e39d1e",
2533
+ "fabric_board.py": "0f075894b3e352194fcaf3fc7d08dee4e9f1c215e859769ed85fa5010e4a1599",
2534
+ "fabric_http.py": "5d7f72af3a73fdbe8c1f487ecf60cef23a7c46289eba542b03aff4706b8218d4",
2535
+ "fabric_openrouter.py": "2017d898c17001d7275f58267de653be7ecf6acd17ad8d9b24ecb7a43e5617d4",
2536
+ "fabric-jsvalue.mjs": "dfbdb8c41584972c9389750a41719374a1cfc5652ed438dd1623025190b0106e",
2537
+ "fabric-time.mjs": "3ff932069038a720df1c68c6492976906737b2e67e5c12d9c81c418597f661de",
2538
+ "fabric-identifiers.mjs": "a18c2a501a4aa170bfd3d99e571ad6b7662649b4d3b9d2a540c002ea42811bbf",
2539
+ "fabric-secrets.mjs": "6ad8ab18ac899ecc2384e8ec6a3fb689b92be3b9a5ce3a59b92a37d52da5d766",
2540
+ "fabric-crypto.mjs": "038e03689701e3c9721a1e7f8e0a4d02a52d6a5d1661b0dc84035edc3ebbfcf6",
2541
+ "fabric-base.mjs": "7804b2df7715dbbf9702a9b61b6b8cef2110f9a52b04cabd8fb0a66458d87512",
2542
+ "fabric-platform.mjs": "a8119d95ebba4d589a9e401f5d6fc8dda62d59d44e4c1ff811d97fac3771ac0c",
2543
+ "fabric-paths.mjs": "2201b4ed7126f260931b63e8386377b4317d8dcad419d109bf22c683e44b7f29",
2544
+ "fabric-estate.mjs": "c4784b0f73d06baaa5f8d104eb33ec21bf5433abfbaed046f24f1d07cf78c782",
2545
+ "fabric-delegation.mjs": "dafde9ea327c88d92dc73d1400282b43dc6f84dd345a2d2b9b154304f65fc9d7",
2546
+ "fabric-share.mjs": "d40a7405d7bd4c3213ed691330b7549afe8e023db0753b582e249714e672010d",
2547
+ "fabric-ceremonies.mjs": "117d4374c9e95c930a38de7adbd4323774798f4fe6ec6f24afcd6f2c7298ca44",
2548
+ "fabric-package-schema.mjs": "cd28092375db0fc21ffab1f0bb7794f7f8979ee08c64d3b9faf4b3845357caa6",
2549
+ "fabric-package.mjs": "49b757eefcc50e4c91585d5a5694bf3c83d0b03d112c1837f1c9f01466797ab5",
2550
+ "fabric-protocols.mjs": "b1fcf14ef1e4f2215111d9fdc89ff5bda3ce2a49aefcd2788feb5a1c12463cbf",
2551
+ "fabric-connections.mjs": "303432e547ce563e90ca9ffcd827f5612f51d3582929834e1add0fdaf6485dcc",
2552
+ "fabric-lock.mjs": "ac219ab0a326d5c43c7c4c255e0c3843d4794de795a60e3af661e7d5350ea052",
2553
+ "fabric-usage.mjs": "e413569620bbc8221bc59e24b27e22d1483825ed18457801ed58d54a57de8114",
2554
+ "fabric-backups.mjs": "849acdc6eba0fff648c8158e61f6b68cccd7bedf0880f7d5ec03a6a20f1e7005",
2555
+ "fabric-descriptor.mjs": "95c0b7ff36c29d7c748f1fa6fb1767d6fe8c97fca7f1ee404559930abbd3cc84",
2556
+ "fabric-auth.mjs": "3ea6796470fcd829bc72a772fb7e4c37532a09d1e37ff41592dcf1a1a3074ca2",
2557
+ "fabric-events.mjs": "c463d6ffcae24229a5c608d8fcea4268d2ccc5024b19fa10e5a452284068fa71",
2558
+ "fabric-logs.mjs": "58467e5719c16e099d05bbedec47a95712506ab03c33f3cd4be1252e8498a946",
2559
+ "fabric-supervisors.mjs": "9d61c1a17538a5e2453e33e5ae347337336748041bc054478a96dd51f3d4fc51",
2560
+ "fabric-service.mjs": "bc67550e52aa6aa9c6d2ed6cbae52418a3e836623e17164166699f953d8aafa8",
2561
+ "fabric-profiles.mjs": "314376cf2deb87f235fb18b11d3b532e211f585b9af0a9302b5f4cc39971b532",
2562
+ "fabric-interop-rules.mjs": "57a8081307956e8c381119b361fa5232a7bf791815be8e3541eb5492820e2ea5",
2563
+ "fabric-interop.mjs": "581983ec3e197e5c2340ad25e8468320c69a2459c90097c7fcf2699766b4fb26",
2564
+ "fabric-hub.mjs": "c005115a864cfd67daa4862097bb14acfe5a60bf653897e5bf8a570dd80f1070",
2565
+ "fabric-board-shapes.mjs": "c1db7b7bd0d58d82bb6d8dfbc3cd1547a256a333052d367e82ff1b3edf2804d7",
2566
+ "fabric-board-link.mjs": "4cd6d4e21713a38c8e56f7d59db3148638b8c27c08e774d96a0f6283c6049e0a",
2567
+ "fabric-board-session.mjs": "e9eefb5d34dc66f6d6b528a9c9e85960fd884e6ea5e179c26cbfe3c054ccf7bc",
2568
+ "fabric-board-fixture.mjs": "83c4b7e33ee47b68385faf2569d38d5c8a0c5cb0f8656090f41e1b0eed2f436e",
2569
+ "fabric-board.mjs": "9e7bfe4d412521982224ef12bebfc2ff255eeeaf8d96cf40234ac18de68e7a59",
2570
+ "fabric-http.mjs": "f886bcb49c4353d039387ac9568738dda26697a94dd07fc6a6204500f0bc27bf",
2571
+ "fabric-openrouter.mjs": "f8f0168691f968b7cd85f36aa98e2167e88cc44f1104ff514fd0c7699b339253",
2572
+ "fabric-package.schema.json": "2ba799f576fad0f608029223c4ccccd122152f1c5dbfa8ce8b31cd63ac92ad44",
2573
+ "share-registration.schema.json": "34b3997eeaf936ebc861342a23045634a7c18c19f01d7ed4198310d81989f5b0"
2574
+ },
2575
+ "0.48.0": {
2576
+ "fabric_jsvalue.py": "ff1bbfae999fb5683e59e31189143f1c85fa9d7780040b5a6952e060a143fc18",
2577
+ "fabric_time.py": "588f383da4ac6a243ea95b6ffc3a0aa99ca94245732dc4937734f190b7af4ae9",
2578
+ "fabric_identifiers.py": "0fd814d521ac3aa39907458265870753046a73f48af3d8581ab864b93b211607",
2579
+ "fabric_secrets.py": "68a0b5e9e90c468ec9ac67b2f0fd1848e08813d6ec1ec14a2a47337eb1a98911",
2580
+ "fabric_crypto.py": "9c34aee20dbe4d6742e4fdbaab68ceb934bbe7dbd3a904c0010824ace16cdeaa",
2581
+ "fabric_base.py": "f62bcc2ed9912660fa875d27a09f60867260cc4925f79873501fd9620be59611",
2582
+ "fabric_platform.py": "a9350dff55ef0fb3a23cdb4d0320a15868b15a8deb10d2eb74d72c58740c284b",
2583
+ "fabric_paths.py": "6c92af34292baddf7e10d3134d0a5b0bb23387741812a9f79c409cf1628bad23",
2584
+ "fabric_estate.py": "f493cd424dae6380d1caad91ffdca2764e51a49a6b1051a77731e3d8755277cd",
2585
+ "fabric_delegation.py": "6638a6039befe3b43f59018bab292e050931af21b544df1044cd5059572ded36",
2586
+ "fabric_share.py": "78506fe1a60829c525dbf6266a0aa0749a2164290ed94da8249a20b4e9be5605",
2587
+ "fabric_ceremonies.py": "a59e83cf4adb2669a9b98008e3d3a0cba991a35cbff97bc604fc82fe81cfd49f",
2588
+ "fabric_package_schema.py": "e623f0408d38e3343ed66222fb812907c6c52e1a286eb1c1176ab2e64640c9ad",
2589
+ "fabric_package.py": "ff533f1b3f92b4ea0f532fcec50b68c610d47549930eef8ad5247692a64dfb3c",
2590
+ "fabric_protocols.py": "28e693282cddff8e4396ae39ce6fa935e875e8ddac7375bea2c189dd6fb04970",
2591
+ "fabric_connections.py": "77d3ccf863c70e9209d21ef45c46a6db9c06237019bc20d54f8d3946873f3f11",
2592
+ "fabric_lock.py": "ba2399d2137c7a7791b7133b9db9cca36693f4eb7e30955b25a75ade814e95ba",
2593
+ "fabric_usage.py": "70e984ec3ff9f4ee31539df8d503f01ef8e6dd870e65e9546c0a2cb4aa040a88",
2594
+ "fabric_backups.py": "02dc5eb66ec956723d3ddba07b6dd15f6a9726d901f4d60edfd02eee57880ceb",
2595
+ "fabric_descriptor.py": "daf16f95b1810283b1386488e006e940a0cdfa5c3c8d09386ac33d29d58df78d",
2596
+ "fabric_auth.py": "55794587750f6c9f40935b74295d83d7096cb4b26a0b36ac292334cd87d8f853",
2597
+ "fabric_events.py": "0086886c2c29dbd5c9d152be9d375c5bdf4a29f89b8095baff8490c2bcd64e47",
2598
+ "fabric_logs.py": "86dc55bfafa2a57264722e2403510a249b95de0e4a722b2e4bc3948e3bd93da9",
2599
+ "fabric_supervisors.py": "a2dbf15aa1c4eb9de42996b413103fa4814afd2e4e66d1ab7315f30e06ab6841",
2600
+ "fabric_service.py": "93368b68c4375dd43997e3b61b82d96743870d5481335461dc0f2d7af7ff3a67",
2601
+ "fabric_profiles.py": "26e21ec29c016000f8ca8053b7b489184854a73be1b7fdc0a18d12990203db9b",
2602
+ "fabric_interop_rules.py": "b5f0ecb1791b4c50c5ac151f987f692ed7001bc74841a8a161125ec775145e09",
2603
+ "fabric_interop.py": "f92fa111328fe3811438f733d8f0eb5295e6941c774123a78e26318bdcb35fc3",
2604
+ "fabric_hub.py": "b9ac852e601b1d54cf61351677a628afc69faeed7d1fc74ebe541c1cf38e0c2a",
2605
+ "fabric_board_shapes.py": "0a0e8d3d40e33dfd8c457aedb85330b2ff25f8c49a33eb6cbd5a5cc8a5007f7e",
2606
+ "fabric_board_link.py": "b5bff994cb615c6a4bb0d88d020e3a59504166180e1fa1aea3be93224f2aeccb",
2607
+ "fabric_board_session.py": "2271834c36c23f6bfa50395cd147e6af99f294ef09325fd15f0759ca8e18d941",
2608
+ "fabric_board_fixture.py": "69761ce205ec640762ec9b591c6903e5a87f844e8d423b041681924027e39d1e",
2609
+ "fabric_board.py": "0f075894b3e352194fcaf3fc7d08dee4e9f1c215e859769ed85fa5010e4a1599",
2610
+ "fabric_http.py": "5d7f72af3a73fdbe8c1f487ecf60cef23a7c46289eba542b03aff4706b8218d4",
2611
+ "fabric_openrouter.py": "2017d898c17001d7275f58267de653be7ecf6acd17ad8d9b24ecb7a43e5617d4",
2612
+ "fabric-jsvalue.mjs": "dfbdb8c41584972c9389750a41719374a1cfc5652ed438dd1623025190b0106e",
2613
+ "fabric-time.mjs": "3ff932069038a720df1c68c6492976906737b2e67e5c12d9c81c418597f661de",
2614
+ "fabric-identifiers.mjs": "a18c2a501a4aa170bfd3d99e571ad6b7662649b4d3b9d2a540c002ea42811bbf",
2615
+ "fabric-secrets.mjs": "6ad8ab18ac899ecc2384e8ec6a3fb689b92be3b9a5ce3a59b92a37d52da5d766",
2616
+ "fabric-crypto.mjs": "038e03689701e3c9721a1e7f8e0a4d02a52d6a5d1661b0dc84035edc3ebbfcf6",
2617
+ "fabric-base.mjs": "7804b2df7715dbbf9702a9b61b6b8cef2110f9a52b04cabd8fb0a66458d87512",
2618
+ "fabric-platform.mjs": "a8119d95ebba4d589a9e401f5d6fc8dda62d59d44e4c1ff811d97fac3771ac0c",
2619
+ "fabric-paths.mjs": "2201b4ed7126f260931b63e8386377b4317d8dcad419d109bf22c683e44b7f29",
2620
+ "fabric-estate.mjs": "b6610aaad9d00d8a7f42955627fb81725950841455bcedfc050abb0e079f0f93",
2621
+ "fabric-delegation.mjs": "dafde9ea327c88d92dc73d1400282b43dc6f84dd345a2d2b9b154304f65fc9d7",
2622
+ "fabric-share.mjs": "d40a7405d7bd4c3213ed691330b7549afe8e023db0753b582e249714e672010d",
2623
+ "fabric-ceremonies.mjs": "117d4374c9e95c930a38de7adbd4323774798f4fe6ec6f24afcd6f2c7298ca44",
2624
+ "fabric-package-schema.mjs": "cd28092375db0fc21ffab1f0bb7794f7f8979ee08c64d3b9faf4b3845357caa6",
2625
+ "fabric-package.mjs": "49b757eefcc50e4c91585d5a5694bf3c83d0b03d112c1837f1c9f01466797ab5",
2626
+ "fabric-protocols.mjs": "dc039ec3612d0090c9ba6a3a949b0b7df54712bead300dcb6cc8b9bc305cd183",
2627
+ "fabric-connections.mjs": "303432e547ce563e90ca9ffcd827f5612f51d3582929834e1add0fdaf6485dcc",
2628
+ "fabric-lock.mjs": "ac219ab0a326d5c43c7c4c255e0c3843d4794de795a60e3af661e7d5350ea052",
2629
+ "fabric-usage.mjs": "1c4d18004639409624ac3516a02f2b60bbf55abdb612e9a96c37e83666eb87a7",
2630
+ "fabric-backups.mjs": "849acdc6eba0fff648c8158e61f6b68cccd7bedf0880f7d5ec03a6a20f1e7005",
2631
+ "fabric-descriptor.mjs": "95c0b7ff36c29d7c748f1fa6fb1767d6fe8c97fca7f1ee404559930abbd3cc84",
2632
+ "fabric-auth.mjs": "3ea6796470fcd829bc72a772fb7e4c37532a09d1e37ff41592dcf1a1a3074ca2",
2633
+ "fabric-events.mjs": "c463d6ffcae24229a5c608d8fcea4268d2ccc5024b19fa10e5a452284068fa71",
2634
+ "fabric-logs.mjs": "58467e5719c16e099d05bbedec47a95712506ab03c33f3cd4be1252e8498a946",
2635
+ "fabric-supervisors.mjs": "9d61c1a17538a5e2453e33e5ae347337336748041bc054478a96dd51f3d4fc51",
2636
+ "fabric-service.mjs": "1b4639a10c530c1241d317c5e9cf3a10cd96e5d9af8b29fc1b1844de5c63bfec",
2637
+ "fabric-profiles.mjs": "314376cf2deb87f235fb18b11d3b532e211f585b9af0a9302b5f4cc39971b532",
2638
+ "fabric-interop-rules.mjs": "57a8081307956e8c381119b361fa5232a7bf791815be8e3541eb5492820e2ea5",
2639
+ "fabric-interop.mjs": "581983ec3e197e5c2340ad25e8468320c69a2459c90097c7fcf2699766b4fb26",
2640
+ "fabric-hub.mjs": "c005115a864cfd67daa4862097bb14acfe5a60bf653897e5bf8a570dd80f1070",
2641
+ "fabric-board-shapes.mjs": "c1db7b7bd0d58d82bb6d8dfbc3cd1547a256a333052d367e82ff1b3edf2804d7",
2642
+ "fabric-board-link.mjs": "4cd6d4e21713a38c8e56f7d59db3148638b8c27c08e774d96a0f6283c6049e0a",
2643
+ "fabric-board-session.mjs": "e9eefb5d34dc66f6d6b528a9c9e85960fd884e6ea5e179c26cbfe3c054ccf7bc",
2644
+ "fabric-board-fixture.mjs": "83c4b7e33ee47b68385faf2569d38d5c8a0c5cb0f8656090f41e1b0eed2f436e",
2645
+ "fabric-board.mjs": "9e7bfe4d412521982224ef12bebfc2ff255eeeaf8d96cf40234ac18de68e7a59",
2646
+ "fabric-http.mjs": "f886bcb49c4353d039387ac9568738dda26697a94dd07fc6a6204500f0bc27bf",
2647
+ "fabric-openrouter.mjs": "f8f0168691f968b7cd85f36aa98e2167e88cc44f1104ff514fd0c7699b339253",
2648
+ "fabric-package.schema.json": "2ba799f576fad0f608029223c4ccccd122152f1c5dbfa8ce8b31cd63ac92ad44",
2649
+ "share-registration.schema.json": "34b3997eeaf936ebc861342a23045634a7c18c19f01d7ed4198310d81989f5b0"
2498
2650
  }
2499
2651
  },
2500
2652
  "data": {
@@ -5,9 +5,9 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.47.0"
8
+ version: "0.48.0"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "87d384ffce2757081b7ab227e5dad3dddd08328f"
10
+ contract-commit: "2f020a696453ae299b123517f14957f6a1354379"
11
11
  ---
12
12
 
13
13
  # Creating Fabric-compatible agents
@@ -136,7 +136,7 @@ python3 <plugin-dir>/skills/adapting-projects-to-fabric/scripts/adapt_project.py
136
136
  The effect class is declared, never defaulted: ask the author what the agent does outside itself when the request
137
137
  does not say. Cost presets are only for a run whose cost varies; their amounts come from the author or measured runs.
138
138
 
139
- Pin exactly contract `0.1.0` at commit `87d384ffce2757081b7ab227e5dad3dddd08328f` and
139
+ Pin exactly contract `0.1.0` at commit `2f020a696453ae299b123517f14957f6a1354379` and
140
140
  read the pinned guide before implementing protocol details. If this skill is installed
141
141
  without its sibling, the scaffolder is absent: create the bundle by hand from the pinned
142
142
  contract's `docs/guides/connecting-compatible-agents.md` and mark the structural check
@@ -19,7 +19,7 @@ compatibility: >-
19
19
  this plugin; Codex/Kimi use command hooks; Hermes/OpenCode/OpenClaw/Pi use native context plugins after opt-in.
20
20
  metadata:
21
21
  author: PassionCode.ai
22
- version: "0.47.0"
22
+ version: "0.48.0"
23
23
  ---
24
24
 
25
25
  # Working with Fabric
@@ -57,7 +57,7 @@ SESSION_ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
57
57
  LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{1,5})")
58
58
 
59
59
 
60
- VERSION = "0.47.0"
60
+ VERSION = "0.48.0"
61
61
 
62
62
 
63
63
  HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code",