@passioncode-ai/fabric-agent-adapter 0.38.0 → 0.39.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 (23) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +4 -2
  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 +4 -3
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +8 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +9 -4
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -2
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +4 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +3 -1
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +7 -1
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +33 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +12 -5
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +16 -3
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +32 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +18 -4
  20. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  21. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +2 -1
  22. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  23. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.39.0 - 2026-10-20
4
+
5
+ ### Changed
6
+
7
+ - **Pinned to contract `c55f2d5`** (FAA-61; DEC-0089 to DEC-0095). `0704218` (0.36.0 to 0.38.0) joins the supported
8
+ revisions. Every new vector is answered by the contract's own functions at the pin.
9
+
10
+ ### Added
11
+
12
+ - **Unmeasured tokens are named** (DEC-0091, contract CT-62). `make_usage_receipt(..., tokens_measured=False)` /
13
+ `makeUsageReceipt(..., { tokensMeasured: false })` writes `tokensMeasured: false`; the report counts such calls in
14
+ `unmeasuredCalls` on the day and the model row, and FAC-SEM-025 in both kits and `check_service.py` refuses a row
15
+ with more unmeasured calls than calls or a day whose count is not its models' sum. Three new usage vectors (25).
16
+ - **A partner in the delegation chain** (DEC-0090, contract CT-58). Both verifiers take `{partner: "org:<id>"}` as the
17
+ innermost actor under at least one agent: it counts toward the depth limit and ends the chain, and the checked chain
18
+ lists it by its `org:` id. Outermost, with an `act` beneath, `personal` or an `ORG:` prefix is `malformed-chain`.
19
+ `is_partner_actor` / `isPartnerActor` are exported. Eight new delegation vectors (90) and thirteen `chainDepths`
20
+ vectors held by both kits.
21
+ - **A partner's publisher key, per organization context** (DEC-0090, FAC-SEM-122). The estate kits read
22
+ `publisherScopes[]`: `placement_trust` / `placementTrust` answer no key for a placement outside its key's scope, and
23
+ `estate-record` and `estate-placement` flag a key scoped twice, a scope over a key `publisherKeys` does not pin, and a
24
+ placement in a context its key's scope does not name. Seven new rule cases (137) and 45 placementTrust cases (64).
25
+ - **Protocol register at the pin**: `coding-agents/1` and `support-quality-daily/1` (DEC-0093, DEC-0095).
26
+
27
+ ### Not here
28
+
29
+ - Minting a partner's token and its grant (FAC-SEM-124) are the issuer's; the kits verify, never mint.
30
+ - `partnerUsage` and FAC-SEM-123 (a partner's access-log reads) are the organization server's.
31
+ - DEC-0089 (folder attribution, FAC-SEM-121) and DEC-0092 (`fabric.project.resolve`) are Fabric's; DEC-0094 (a device
32
+ key in a file, FAC-SEM-125) is the device's and its server's. No kit reads them.
33
+
3
34
  ## 0.38.0 - 2026-10-20
4
35
 
5
36
  ### Changed
package/README.md CHANGED
@@ -95,7 +95,9 @@ checks the interop rules. A job a provider limit paused or stopped, and an effec
95
95
  (`quota-exhausted`, `outcome-unknown`) and keys per caller (contract DEC-0087). The estate kits check share grants into
96
96
  an organization (`scripts/fabric_share.py`, DEC-0086) and the estate ceremonies: a revision's record rules, a headless
97
97
  machine's enroll token, the nearby pairing by a comparison code and the hub's single use of a token
98
- (`scripts/fabric_ceremonies.py`, DEC-0084); each has a Node twin. An agent that is not a service is announced with a provider
98
+ (`scripts/fabric_ceremonies.py`, DEC-0084); each has a Node twin. A partner's publisher key is trusted only in the
99
+ organization contexts its scope names, and a partner may sit innermost in a delegation chain (DEC-0090); a usage report
100
+ counts calls whose tokens are unknown in `unmeasuredCalls` (DEC-0091). An agent that is not a service is announced with a provider
99
101
  entry: `adapting-projects-to-fabric/scripts/fabric_provider.py`.
100
102
 
101
103
  For provider bundles the adapting skill helps an agent author:
@@ -260,7 +262,7 @@ Scaffolding is non-destructive by default. It writes only the paths declared in
260
262
  The default pin for new bundles is [`fabric-contract.lock.json`](fabric-contract.lock.json):
261
263
 
262
264
  - version: `0.1.0`
263
- - commit: `07042181bfae2a481aa0da1a5651f24c76669c48`
265
+ - commit: `c55f2d5a11c9a0197e958e87212fca7ae1154769`
264
266
 
265
267
  Every other mention of the default contract revision — this section, the skills' metadata, the
266
268
  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.38.0",
3
+ "version": "0.39.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.38.0",
6
+ "version": "0.39.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.38.0"
8
+ version: "0.39.0"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "07042181bfae2a481aa0da1a5651f24c76669c48"
10
+ contract-commit: "c55f2d5a11c9a0197e958e87212fca7ae1154769"
11
11
  ---
12
12
 
13
13
  # Adapting projects to Fabric
@@ -70,7 +70,7 @@ Use exactly:
70
70
 
71
71
  - contract version `0.1.0`;
72
72
  - repository `https://github.com/passioncode-ai/fabric-agent-contract`;
73
- - commit `07042181bfae2a481aa0da1a5651f24c76669c48`.
73
+ - commit `c55f2d5a11c9a0197e958e87212fca7ae1154769`.
74
74
 
75
75
  This is the default for new bundles. When checking an issued legacy bundle, preserve its
76
76
  explicit supported lock and use that exact clean checkout. Never silently upgrade or
@@ -21,10 +21,10 @@ import fabric_secrets as _secrets # noqa: E402 - the contract's credential dete
21
21
 
22
22
  CONTRACT_VERSION = "0.1.0"
23
23
  CONTRACT_REPOSITORY = "https://github.com/passioncode-ai/fabric-agent-contract"
24
- CONTRACT_COMMIT = "07042181bfae2a481aa0da1a5651f24c76669c48"
24
+ CONTRACT_COMMIT = "c55f2d5a11c9a0197e958e87212fca7ae1154769"
25
25
  # #region supported-contract-revisions — docs: README.md#contract-pin
26
26
  # Only this declaration may name a legacy revision in live files.
27
- SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "d8146cfb0a1c74fc6432284c4356bb0c864b37b5", "82e163665e156a08aa3b92e4711015582c06162c", "1636fa97262f4a26e2086572d540c2ee701e0010", "f00a7685e3861c27f7899c033fb850a145a1708f", "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b", "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0", "b291bf09e0a9f9fd3c16cf540832cd021203b7d1", "0cacd08b0e95b6abf131eae1206b60792138bc3c", "2eb91c00e852d9c31d804310d38fad91589fcbac", "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5", "a83bec6b47776af688402c18efec507143061f68", "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
27
+ SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "07042181bfae2a481aa0da1a5651f24c76669c48", "d8146cfb0a1c74fc6432284c4356bb0c864b37b5", "82e163665e156a08aa3b92e4711015582c06162c", "1636fa97262f4a26e2086572d540c2ee701e0010", "f00a7685e3861c27f7899c033fb850a145a1708f", "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b", "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0", "b291bf09e0a9f9fd3c16cf540832cd021203b7d1", "0cacd08b0e95b6abf131eae1206b60792138bc3c", "2eb91c00e852d9c31d804310d38fad91589fcbac", "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5", "a83bec6b47776af688402c18efec507143061f68", "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
28
28
  # #endregion supported-contract-revisions
29
29
  INTEROP_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
30
30
  MCP_REVISION = "2026-07-28"
@@ -18,10 +18,10 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
18
18
  compatibility: Python 3.9+ or Node.js 20+ for the kits; 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.
19
19
  metadata:
20
20
  author: PassionCode.ai
21
- version: "0.38.0"
21
+ version: "0.39.0"
22
22
  contract-version: "0.1.0"
23
23
  extension: "fabric-service/0.1"
24
- extension-commit: "07042181bfae2a481aa0da1a5651f24c76669c48"
24
+ extension-commit: "c55f2d5a11c9a0197e958e87212fca7ae1154769"
25
25
  ---
26
26
 
27
27
  # Building Fabric services
@@ -84,7 +84,8 @@ effect nobody saw, have their own codes and a key per caller (DEC-0087). Read
84
84
  [the interop reference](references/interop.md) before writing an MCP tool; the helpers
85
85
  are in [`scripts/fabric_interop.py`](scripts/fabric_interop.py) and
86
86
  [`scripts/fabric-interop.mjs`](scripts/fabric-interop.mjs). When the callers are agents of the estate, the
87
- service checks each call's delegation token (DEC-0039): read [the delegation reference](references/delegation.md);
87
+ service checks each call's delegation token (DEC-0039; a partner's agent carries the partner innermost, DEC-0090):
88
+ read [the delegation reference](references/delegation.md);
88
89
  the verifier is [`scripts/fabric_delegation.py`](scripts/fabric_delegation.py) /
89
90
  [`scripts/fabric-delegation.mjs`](scripts/fabric-delegation.mjs). A service on the operator's machines that
90
91
  applies the estate record (DEC-0055) verifies each revision first and trusts only the keys it certifies, and runs an
@@ -63,6 +63,14 @@ job's own capability and effect, and only for the principal and caller that star
63
63
  `scope-not-granted`); `on_input` gets the checked delegation on its context. Start jobs with `ctx.start_job()` so the
64
64
  job records who started it.
65
65
 
66
+ **A partner in the chain** (DEC-0090). A token a partner's agent presents for a customer organization carries the
67
+ partner as the innermost actor: `{partner: "org:<id>"}`, exactly that member, under at least one agent. It counts
68
+ toward the depth limit and ends the chain; outermost, with an `act` beneath, or as `personal` it is
69
+ `malformed-chain`. A node with a `sub` beside `partner` is an agent, checked as one. `ctx.delegation["chain"]` lists a
70
+ node with `partner` by its `org:` id. `chain_depth(act)` /
71
+ `chainDepth(act)` answer the contract's depth, infinity for a chain that is not one. Minting a partner's token (its
72
+ grant, FAC-SEM-124) is the issuer's, not the kit's.
73
+
66
74
  ## Approvals
67
75
 
68
76
  An action with an external effect may need the operator's approval (DL-17, DL-18). The order is fixed: the token,
@@ -56,7 +56,7 @@ It checks, in the order of the ER table:
56
56
  7. **The window.** At most 30 days. A revision may be issued up to 60 s ahead of the verifier's clock (DEC-0066,
57
57
  `CLOCK_SKEW_SECONDS`); earlier than that it is `not-yet-valid`.
58
58
  8. **The record's own rules.** A violation is `rule-violation` with its `codes`, `FAC-SEM-072…076`, `FAC-SEM-110`,
59
- `FAC-SEM-111` and `FAC-SEM-092`
59
+ `FAC-SEM-111`, `FAC-SEM-122` and `FAC-SEM-092`
60
60
  (each passkey the record lists is a signing credential, its origins within its RP ID).
61
61
 
62
62
  Dates are strict RFC 3339: a `T`, an offset, and a day that exists. `:60` and a space are refused. Passkey JWK `x`
@@ -113,6 +113,10 @@ of `[A-Za-z0-9][A-Za-z0-9_-]{0,127}`. Absent is `personal`; `null` is not absent
113
113
  - **A share grant is never personal** (FAC-SEM-110). A grant to a principal outside the estate (`principalKind:
114
114
  external`) is used in that principal's organization; `context: "personal"` on it is a finding, and an absent one
115
115
  means not stated. `grant_context_findings(grant, at)` / `grantContextFindings(grant, at)` check one grant on its own.
116
+ - **A partner's publisher key is trusted only where its scope says** (DEC-0090, FAC-SEM-122). `publisherScopes[]`
117
+ names the keys that are a partner's (`kid`, `partner: org:<id>`, `contexts`); a placement under such a key is in one
118
+ of the organization contexts listed, never `personal` or another organization. A key is scoped once, and only a key
119
+ `publisherKeys` pins. A key no scope names keeps DEC-0051's meaning, trusted in any context.
116
120
 
117
121
  Every rule is total on hostile JSON, as the contract's are: an `instancePath` names an item by its index as written (a
118
122
  non-object before it does not shift it), and a value whose own `toString` member is data is shown as its JSON. Both kits
@@ -174,14 +178,15 @@ sharer's node's and the organization server's, not this kit's.
174
178
  ## Checking a record you produce
175
179
 
176
180
  `estate_record_rules(kind, value)` / `estateRecordRules(kind, value)` are the contract's rules, finding for finding:
177
- - `estate-record` (FAC-SEM-072…076, 110, 111);
181
+ - `estate-record` (FAC-SEM-072…076, 110, 111, 122);
178
182
  - `estate-record-revision` `{parent, next}` (072);
179
- - `estate-placement` `{record, machine, package, context?}` (074, 075, 111): the package placed on that machine in that
183
+ - `estate-placement` `{record, machine, package, context?}` (074, 075, 111, 122): the package placed on that machine in that
180
184
  context, `personal` when `context` is absent.
181
185
 
182
186
  `placement_trust(record, machine, agentRef, context="personal")` / `placementTrust(record, machine, agentRef, context)`
183
187
  names the publisher keys a package verifier trusts for the package placed in that context. A caller that passes no
184
- context gets the personal placement, as before contexts.
188
+ context gets the personal placement, as before contexts. A partner's key placed outside the contexts its scope names
189
+ answers `{publisher: []}`: the placement exists, and no key may verify it (DEC-0090).
185
190
 
186
191
  ## What the kit does not do
187
192
 
@@ -102,8 +102,13 @@ version `range`. The partner packs and signs every package with its own publishe
102
102
  machine pins that key and `verify` refuses a package signed by any other. One signed package serves every company: what
103
103
  differs per company (a display name, a brand, an address) is placement configuration, never a second package.
104
104
 
105
- Still to come, each waiting on its owner: trusting a partner's key for one organization only (contract CT-58), a
106
- partner's own space in a catalog and publishing to it (FAA-44, Vesta PA-03).
105
+ The company's estate record trusts the partner's key for that company only (DEC-0090): `publisherScopes[]` names the
106
+ key, the partner and the organization contexts, and `placement_trust` / `placementTrust` hand the key to a placement in
107
+ one of those contexts and to no other (FAC-SEM-122, [the estate record reference](estate-record.md)). The partner's own
108
+ agents call the company's agents with the partner innermost in the delegation chain ([delegation](delegation.md)).
109
+
110
+ Still to come, each waiting on its owner: a partner's own space in a catalog and publishing to it (FAA-44, Vesta
111
+ PA-03).
107
112
 
108
113
  ## What this version does not do
109
114
 
@@ -1,6 +1,6 @@
1
1
  # fabric-service/0.1 — wire reference
2
2
 
3
- Pinned to `fabric-agent-contract` commit `07042181bfae2a481aa0da1a5651f24c76669c48`
3
+ Pinned to `fabric-agent-contract` commit `c55f2d5a11c9a0197e958e87212fca7ae1154769`
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;
@@ -36,6 +36,10 @@ ledger.record(k.makeUsageReceipt('openrouter', model, { inputTokens, outputToken
36
36
  records `cost_usd=None`. The report counts it in `unpricedCalls`, and a host shows a lower bound
37
37
  («≥ $x») or «unknown». A call that is really free (a template, a cached answer) records
38
38
  `cost_usd=0.0` with `price-list`.
39
+ - **Unknown tokens are named too** (DEC-0091). When the provider gave no token count (a stream cut off, a local
40
+ model without a counter), pass `tokens_measured=False` / `tokensMeasured: false` with the tokens you have (often 0).
41
+ The receipt carries `tokensMeasured: false`, and the report counts the call in `unmeasuredCalls` on its day and model
42
+ row, never more than `calls`; a host then shows the token totals as a lower bound.
39
43
  - **No content.** A receipt carries no prompt, output, caller or user id. The kits accept only the
40
44
  fields above.
41
45
  - If the service already keeps a ledger (a jobs table with token counts), build the receipts from
@@ -920,12 +920,14 @@ def usage_problems(report: Any, service_id: str, instance: str) -> List[str]:
920
920
  days = report.get("days")
921
921
  if not isinstance(days, list) or len(days) > fs.USAGE_DAYS:
922
922
  return out + ["days is not a list of at most 31"]
923
- fields = ("calls", "unpricedCalls", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens")
923
+ fields = ("calls", "unpricedCalls", "unmeasuredCalls", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens")
924
924
 
925
925
  def priced(row: Dict[str, Any], at: str) -> None:
926
926
  calls, unpriced, cost = row.get("calls", 0), row.get("unpricedCalls", 0), row.get("costUsd")
927
927
  if unpriced > calls:
928
928
  out.append("%s: more unpriced calls than calls" % at)
929
+ if row.get("unmeasuredCalls", 0) > calls:
930
+ out.append("%s: more calls with unknown tokens than calls (DEC-0091)" % at)
929
931
  if calls and unpriced == calls and cost is not None:
930
932
  out.append("%s: every call is unpriced, so the cost must be null, not %r" % (at, cost))
931
933
  if unpriced < calls and cost is None:
@@ -179,10 +179,15 @@ function authentic(token, jwks, typ) {
179
179
  }
180
180
 
181
181
  /** Depth of an actor chain; Infinity for a chain that is not one (a missing or non-string sub at any level). */
182
+ const ORG_CONTEXT = /^org:[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
183
+ /** DEC-0090: a partner actor is exactly {partner: org:<id>}, never with a sub, a session or an act. */
184
+ export const isPartnerActor = (node) => isObject(node) && typeof node.partner === 'string' && ORG_CONTEXT.test(node.partner) && Object.keys(node).length === 1;
185
+
182
186
  export function chainDepth(act) {
183
187
  let depth = 0;
184
188
  let node = act;
185
189
  while (node !== undefined) {
190
+ if (depth > 0 && isPartnerActor(node)) return depth + 1; // a partner actor ends the chain, never begins it
186
191
  if (!isObject(node) || !isString(node.sub)) return Number.POSITIVE_INFINITY;
187
192
  depth += 1;
188
193
  if (depth > 64) return depth;
@@ -193,7 +198,7 @@ export function chainDepth(act) {
193
198
 
194
199
  function chainOf(act) {
195
200
  const chain = [];
196
- for (let node = act; node; node = node.act) chain.push(node.sub);
201
+ for (let node = act; node; node = 'act' in node ? node.act : undefined) chain.push('partner' in node ? node.partner : node.sub);
197
202
  return chain;
198
203
  }
199
204
 
@@ -212,6 +217,7 @@ function actorChainDepth(act) {
212
217
  if (act === undefined) return undefined;
213
218
  let depth = 0;
214
219
  for (let node = act; node !== undefined; node = node.act) {
220
+ if (depth > 0 && isPartnerActor(node)) return depth + 1; // DEC-0090: innermost only, it ends the chain
215
221
  if (!isObject(node) || typeof node.sub !== 'string' || !AGENT_ID.test(node.sub)) return undefined;
216
222
  if (node.session !== undefined && !(typeof node.session === 'string' && OPAQUE_ID.test(node.session))) return undefined;
217
223
  depth += 1;
@@ -300,9 +300,17 @@ export function placementTrust(record, machineId, agentRef, context = PERSONAL_C
300
300
  const machine = list(record.machines).find((entry) => entry.id === machineId);
301
301
  const placement = list(machine?.packages).find((entry) => entry.agentRef === agentRef && contextOf(entry) === context);
302
302
  if (!placement) return undefined;
303
+ // DEC-0090: a partner's key is trusted only in the contexts its scope names.
304
+ if (!scopeAdmits(record, placement.publisherKid, context)) return { publisher: [] };
303
305
  return { publisher: list(record.publisherKeys).filter((key) => key.kid === placement.publisherKid) };
304
306
  }
305
307
 
308
+ /** Whether a placement in `context` under `kid` may use that key (DEC-0090): an unscoped key anywhere, a scoped one only in its contexts. */
309
+ function scopeAdmits(record, kid, context) {
310
+ const scopes = list(record.publisherScopes).filter((scope) => scope.kid === kid);
311
+ return scopes.length === 0 || scopes.some((scope) => Array.isArray(scope.contexts) && scope.contexts.includes(context));
312
+ }
313
+
306
314
  const MAX_WINDOW_MS = MAX_VALIDITY_SECONDS * 1000;
307
315
  const isRevision = (value) => Number.isSafeInteger(value) && value >= 1;
308
316
  const refused = (fault, detail) => ({ certified: false, fault, detail });
@@ -472,6 +480,27 @@ function placements(value, flag) {
472
480
  });
473
481
  }
474
482
 
483
+ /**
484
+ * FAC-SEM-122 on one record (DEC-0090): a partner's publisher key is scoped once, to a key the record pins, and a
485
+ * placement under it is in one of the organization contexts its scope names, never `personal` or another.
486
+ */
487
+ function partnerScopes(value, flag) {
488
+ const pinned = new Set(list(value.publisherKeys).map((key) => key.kid));
489
+ const scoped = new Set();
490
+ indexed(value.publisherScopes).forEach(([scope, index]) => {
491
+ if (scoped.has(scope.kid)) flag(`/publisherScopes/${index}/kid`, `publisher key ${show(scope.kid)} is scoped twice`);
492
+ scoped.add(scope.kid);
493
+ if (!pinned.has(scope.kid)) flag(`/publisherScopes/${index}/kid`, `publisher key ${show(scope.kid)} is not in publisherKeys, so the scope limits no key`);
494
+ });
495
+ indexed(value.machines).forEach(([machine, m]) => {
496
+ indexed(machine.packages).forEach(([placement, p]) => {
497
+ if (!scopeAdmits(value, placement.publisherKid, contextOf(placement))) {
498
+ flag(`/machines/${m}/packages/${p}/publisherKid`, `publisher key ${show(placement.publisherKid)} is a partner's, trusted only in the contexts its scope names, not in ${show(contextOf(placement))}`);
499
+ }
500
+ });
501
+ });
502
+ }
503
+
475
504
  function secretReferences(value, flag) {
476
505
  const machineIds = new Set(list(value.machines).map((machine) => machine.id));
477
506
  indexed(value.machines).forEach(([machine, m]) => {
@@ -573,6 +602,7 @@ function recordFindings(value) {
573
602
  revisionWhole(value, flagger(findings, 'FAC-SEM-072'));
574
603
  keys(value, flagger(findings, 'FAC-SEM-073'));
575
604
  placements(value, flagger(findings, 'FAC-SEM-074'));
605
+ partnerScopes(value, flagger(findings, 'FAC-SEM-122'));
576
606
  secretReferences(value, flagger(findings, 'FAC-SEM-075'));
577
607
  grantsAndRevocations(value, flagger(findings, 'FAC-SEM-076'));
578
608
  indexed(value.shareGrants).forEach(([grant, index]) => findings.push(...grantContextFindings(grant, `/shareGrants/${index}`)));
@@ -613,6 +643,9 @@ function installation(value) {
613
643
  indexed(machine.secrets).forEach(([secret, index]) => {
614
644
  if (secret.agentRef === pkg.agentRef && contextOf(secret) === context && !declared.has(secret.slot)) flag075(`/record/machines/${machineIndex}/secrets/${index}/slot`, `the package declares no slot ${show(secret.slot)}: a secret goes only where a placed package declares it`);
615
645
  });
646
+ if (placement && !scopeAdmits(record, placement.publisherKid, context)) {
647
+ flagger(findings, 'FAC-SEM-122')(`/record/machines/${machineIndex}/packages/${placementIndex}/publisherKid`, `publisher key ${show(placement.publisherKid)} is a partner's, not trusted in ${show(context)}`);
648
+ }
616
649
  if (placement) {
617
650
  const flag111 = flagger(findings, 'FAC-SEM-111');
618
651
  for (const [suffix, message] of placementGrantProblems(record, placement)) flag111(`/record/machines/${machineIndex}/packages/${placementIndex}${suffix}`, message);
@@ -862,13 +862,13 @@ export function buildWellKnown({ id, instance, name, version, build, startedAt,
862
862
 
863
863
  /** Each contract protocol's published revisions, oldest first (the contract's `PROTOCOL_REVISIONS`); a test holds it equal to the contract's. */
864
864
  export const PROTOCOL_REVISIONS = new Map(Object.entries({
865
- 'access-log': ['1'], approval: ['1'], 'activity-summary': ['1'], 'enroll-token': ['1'], entitlement: ['1'], 'entitlement-key-set': ['1'], 'estate-record': ['1'], 'execution-packet': ['1'],
865
+ 'access-log': ['1'], approval: ['1'], 'activity-summary': ['1'], 'coding-agents': ['1'], 'enroll-token': ['1'], entitlement: ['1'], 'entitlement-key-set': ['1'], 'estate-record': ['1'], 'execution-packet': ['1'],
866
866
  'fabric-activity': ['0.1'], 'fabric-dashboard': ['0.1'], 'fabric-delegation': ['0.1'], 'fabric-device': ['0.1'],
867
867
  'fabric-interop': ['0.1', '0.2'], 'fabric-local-channel': ['0.1'], 'fabric-local-runner': ['0.1'], 'fabric-operator-channel': ['0.1'],
868
868
  'fabric-package': ['1'], 'fabric-project-comms': ['0.1'], 'fabric-proposal': ['1'], 'fabric-provider': ['0.1'], 'fabric-relay': ['0.1'],
869
869
  'fabric-secret-envelope': ['1'], 'fabric-service': ['0.1'], 'fabric-settings-backup': ['1'], heartbeat: ['1'], 'learning-note': ['1'],
870
870
  memory: ['0.1'], 'pairing-message': ['1'], pipeline: ['0.1'], 'revocation-delta': ['1'], 'session-handoff': ['1'],
871
- 'share-registration': ['1'], 'share-registration-receipt': ['1'], 'window-lease': ['1'],
871
+ 'share-registration': ['1'], 'share-registration-receipt': ['1'], 'support-quality-daily': ['1'], 'window-lease': ['1'],
872
872
  }));
873
873
  export const MAX_PROTOCOLS = 64;
874
874
  const REVISION = /^([a-z][a-z0-9.-]{0,63})\/([0-9]{1,4}(?:\.[0-9]{1,4}){0,2})$/;
@@ -1023,7 +1023,9 @@ const COUNTS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTok
1023
1023
  * One model call. `projectId` (a URI, kept in its normal form, DEC-0076) and `taskRunId` (the service's own run id) attribute
1024
1024
  * it to the project the service ran it for (DEC-0065); they never name a caller, session or person.
1025
1025
  */
1026
- export function makeUsageReceipt(provider, model, { inputTokens, outputTokens, cacheReadTokens = 0, cacheWriteTokens = 0, costUsd = null, costBasis, at, route, projectId, taskRunId } = {}) {
1026
+ // tokensMeasured: false marks a call whose token counts are a lower bound or unknown (DEC-0091): the report counts it
1027
+ // in unmeasuredCalls and its token totals are a lower bound.
1028
+ export function makeUsageReceipt(provider, model, { inputTokens, outputTokens, cacheReadTokens = 0, cacheWriteTokens = 0, costUsd = null, costBasis, at, route, projectId, taskRunId, tokensMeasured = true } = {}) {
1027
1029
  if (!PROVIDER.test(provider ?? '')) throw new ServiceError('provider must be a lowercase name such as anthropic or openrouter.');
1028
1030
  model = String(model ?? '').trim();
1029
1031
  if (!model || model.length > 128) throw new ServiceError('model must be 1 to 128 characters.');
@@ -1033,10 +1035,11 @@ export function makeUsageReceipt(provider, model, { inputTokens, outputTokens, c
1033
1035
  const basis = costBasis ?? (costUsd === null ? 'unknown' : 'provider');
1034
1036
  if (!COST_BASES.includes(basis) || (costUsd === null) !== (basis === 'unknown')) throw new ServiceError('costBasis is provider or price-list with a cost, unknown without one.');
1035
1037
  if (route !== undefined && !USAGE_ROUTES.includes(route)) throw new ServiceError('route is subscription, key or platform-metered (DEC-0046, DEC-0061).');
1038
+ if (tokensMeasured !== true && tokensMeasured !== false) throw new ServiceError('tokensMeasured is true or false.');
1036
1039
  if (taskRunId !== undefined && projectId === undefined) throw new ServiceError('a task run belongs to a project: give projectId with taskRunId.');
1037
1040
  if (projectId !== undefined && !(typeof projectId === 'string' && /^[A-Za-z][A-Za-z0-9+.-]*:\S+$/.test(projectId))) throw new ServiceError('projectId is the URI of the project the service ran the call for.');
1038
1041
  if (taskRunId !== undefined && !(typeof taskRunId === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(taskRunId))) throw new ServiceError("taskRunId is the service's own run id: ^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$.");
1039
- return { at: at ?? nowIso(), provider, model, ...counts, costUsd, costBasis: basis, ...(route === undefined ? {} : { route }),
1042
+ return { at: at ?? nowIso(), provider, model, ...counts, costUsd, costBasis: basis, ...(tokensMeasured ? {} : { tokensMeasured: false }), ...(route === undefined ? {} : { route }),
1040
1043
  ...(projectId === undefined ? {} : { projectId: normalIdentifier(projectId) }), ...(taskRunId === undefined ? {} : { taskRunId }) };
1041
1044
  }
1042
1045
 
@@ -1104,6 +1107,8 @@ function pricedRow(row, at) {
1104
1107
  const calls = usageCount(row.calls), unpriced = usageCount(row.unpricedCalls);
1105
1108
  const flag = (p, message) => [{ code: 'FAC-SEM-025', instancePath: p, message }];
1106
1109
  if (unpriced > calls) return flag(`${at}/unpricedCalls`, 'more unpriced calls than calls');
1110
+ // DEC-0091: calls whose token count is unknown are some of the calls, never more.
1111
+ if (usageCount(row.unmeasuredCalls) > calls) return flag(`${at}/unmeasuredCalls`, 'more calls with unknown tokens than calls');
1107
1112
  if (calls > 0 && unpriced === calls && row.costUsd !== null) return flag(`${at}/costUsd`, 'every call is unpriced, so the cost is unknown (null), not a number');
1108
1113
  if (unpriced < calls && row.costUsd === null) return flag(`${at}/costUsd`, 'priced calls carry a cost; null is only for a row with no priced call');
1109
1114
  return [];
@@ -1133,7 +1138,7 @@ export function usageFindings(report) {
1133
1138
  if (spent) flag(`${at}/byModel`, 'a day with calls, tokens or cost names the models they belong to');
1134
1139
  return;
1135
1140
  }
1136
- for (const field of ['calls', 'unpricedCalls', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens']) {
1141
+ for (const field of ['calls', 'unpricedCalls', 'unmeasuredCalls', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens']) {
1137
1142
  const sum = models.reduce((n, m) => n + Number(m[field] ?? 0), 0);
1138
1143
  if (Number(day[field] ?? 0) !== sum) flag(`${at}/${field}`, `the day's ${field} is not the sum of its models (${String(day[field] ?? 0)} ≠ ${sum})`);
1139
1144
  }
@@ -1272,6 +1277,7 @@ export function usageReport(receipts, { id, instance = 'default', now = Date.now
1272
1277
  row.calls += 1;
1273
1278
  for (const c of COUNTS) row[c] += Number(r[c] ?? 0);
1274
1279
  if (r.costUsd === null || r.costUsd === undefined) row.unpricedCalls += 1; else row.cost += r.costUsd;
1280
+ if (r.tokensMeasured === false) row.unmeasuredCalls = (row.unmeasuredCalls ?? 0) + 1; // absent is 0 (DEC-0091)
1275
1281
  row.bases.add(r.costBasis ?? 'unknown');
1276
1282
  }
1277
1283
  const outDays = [...days.keys()].sort().map((date) => {
@@ -1281,6 +1287,7 @@ export function usageReport(receipts, { id, instance = 'default', now = Date.now
1281
1287
  const day = emptyRow({ date });
1282
1288
  for (const m of models) {
1283
1289
  for (const c of ['calls', 'unpricedCalls', ...COUNTS]) day[c] += m[c];
1290
+ if (m.unmeasuredCalls) day.unmeasuredCalls = (day.unmeasuredCalls ?? 0) + m.unmeasuredCalls;
1284
1291
  day.cost += m.costUsd ?? 0;
1285
1292
  }
1286
1293
  return { ...closeRow(day), byModel: models };
@@ -334,11 +334,22 @@ def _authentic(token: Any, jwks: Dict[str, Any], typ: str) -> Dict[str, Any]:
334
334
  return {"compact": compact, "kid": kid, "x": found["x"]}
335
335
 
336
336
 
337
+ _ORG_CONTEXT = re.compile(r"org:[A-Za-z0-9][A-Za-z0-9_-]{0,127}\Z")
338
+
339
+
340
+ def is_partner_actor(node: Any) -> bool:
341
+ """DEC-0090: a partner actor is exactly {partner: org:<id>}, never with a sub, a session or an act."""
342
+ return isinstance(node, dict) and len(node) == 1 and isinstance(node.get("partner"), str) and _ORG_CONTEXT.match(node["partner"]) is not None
343
+
344
+
337
345
  def chain_depth(act: Any) -> float:
338
- """Depth of an actor chain; infinity for a chain that is not one (a missing or non-string sub at any level)."""
346
+ """Depth of an actor chain; infinity for a chain that is not one (a missing or non-string sub at any level). A
347
+ partner actor ends the chain and counts, never begins it (DEC-0090)."""
339
348
  depth = 0
340
349
  node = act
341
350
  while node is not _MISSING:
351
+ if depth > 0 and is_partner_actor(node):
352
+ return depth + 1
342
353
  if not isinstance(node, dict) or not _is_string(node.get("sub")):
343
354
  return math.inf
344
355
  depth += 1
@@ -352,8 +363,8 @@ def _chain_of(act: Dict[str, Any]) -> List[str]:
352
363
  chain = []
353
364
  node: Any = act
354
365
  while isinstance(node, dict):
355
- chain.append(node["sub"])
356
- node = node.get("act")
366
+ chain.append(node["partner"] if "partner" in node else node["sub"]) # the contract's chainOf, partner first
367
+ node = node.get("act") if "act" in node else None
357
368
  return chain
358
369
 
359
370
 
@@ -379,6 +390,8 @@ def _actor_chain_depth(act: Any) -> Optional[int]:
379
390
  depth = 0
380
391
  node = act
381
392
  while node is not _MISSING:
393
+ if depth > 0 and is_partner_actor(node): # DEC-0090: innermost only, it ends the chain
394
+ return depth + 1
382
395
  if not isinstance(node, dict) or not _full(AGENT_ID, node.get("sub")):
383
396
  return None
384
397
  if "session" in node and not _full(OPAQUE_ID, node["session"]):
@@ -734,9 +734,18 @@ def placement_trust(record: Dict[str, Any], machine_id: str, agent_ref: str, con
734
734
  if _same(_get(p, "agentRef"), agent_ref) and _same(_context_of(p), context)), None)
735
735
  if placement is None:
736
736
  return None
737
+ if not _scope_admits(record, _get(placement, "publisherKid"), context): # DEC-0090: a partner's key, in its contexts only
738
+ return {"publisher": []}
737
739
  return {"publisher": [k for k in _list(_get(record, "publisherKeys")) if _same(_get(k, "kid"), _get(placement, "publisherKid"))]}
738
740
 
739
741
 
742
+ def _scope_admits(record: Any, kid: Any, context: Any) -> bool:
743
+ """DEC-0090: a key no `publisherScopes[]` entry names is trusted in any context; a partner's key, only in a context
744
+ one of its scopes lists."""
745
+ scopes = [s for s in _list(_get(record, "publisherScopes")) if _same(_get(s, "kid"), kid)]
746
+ return not scopes or any(isinstance(_get(s, "contexts"), list) and _contains(s["contexts"], context) for s in scopes)
747
+
748
+
740
749
  #: v4 §7, Freshness: an estate revision's window is at most 30 days (DEC-0055 item 2).
741
750
  _MAX_WINDOW_MS = MAX_VALIDITY_SECONDS * 1000
742
751
 
@@ -953,6 +962,25 @@ def _placements(value: Dict[str, Any], flag) -> None:
953
962
  flag("%s/publisherKid" % at, "publisher key %s is not in publisherKeys, so the machine pins no key to verify the package under" % _show(_get(placement, "publisherKid")))
954
963
 
955
964
 
965
+ def _partner_scopes(value: Dict[str, Any], flag) -> None:
966
+ """FAC-SEM-122 on one record (DEC-0090): a partner's publisher key is scoped once, to a key the record pins, and a
967
+ placement under it is in one of the organization contexts its scope names, never ``personal`` or another."""
968
+ pinned = [_get(k, "kid") for k in _list(_get(value, "publisherKeys"))]
969
+ scoped: List[Any] = []
970
+ for scope, index in _indexed(_get(value, "publisherScopes")):
971
+ if _contains(scoped, _get(scope, "kid")):
972
+ flag("/publisherScopes/%d/kid" % index, "publisher key %s is scoped twice" % _show(_get(scope, "kid")))
973
+ scoped.append(_get(scope, "kid"))
974
+ if not _contains(pinned, _get(scope, "kid")):
975
+ flag("/publisherScopes/%d/kid" % index, "publisher key %s is not in publisherKeys, so the scope limits no key" % _show(_get(scope, "kid")))
976
+ for machine, m in _indexed(_get(value, "machines")):
977
+ for placement, p in _indexed(_get(machine, "packages")):
978
+ if not _scope_admits(value, _get(placement, "publisherKid"), _context_of(placement)):
979
+ flag("/machines/%d/packages/%d/publisherKid" % (m, p),
980
+ "publisher key %s is a partner's, trusted only in the contexts its scope names, not in %s"
981
+ % (_show(_get(placement, "publisherKid")), _show(_context_of(placement))))
982
+
983
+
956
984
  def _secret_references(value: Dict[str, Any], flag) -> None:
957
985
  machine_ids = [_get(m, "id") for m in _list(_get(value, "machines"))]
958
986
  for machine, m in _indexed(_get(value, "machines")):
@@ -1110,6 +1138,7 @@ def _record_findings(value: Dict[str, Any]) -> List[Dict[str, str]]:
1110
1138
  _revision_whole(value, _finding(findings, "FAC-SEM-072"))
1111
1139
  _keys(value, _finding(findings, "FAC-SEM-073"))
1112
1140
  _placements(value, _finding(findings, "FAC-SEM-074"))
1141
+ _partner_scopes(value, _finding(findings, "FAC-SEM-122"))
1113
1142
  _secret_references(value, _finding(findings, "FAC-SEM-075"))
1114
1143
  _grants_and_revocations(value, _finding(findings, "FAC-SEM-076"))
1115
1144
  for grant, index in _indexed(_get(value, "shareGrants")): # FAC-SEM-110 (DEC-0082)
@@ -1156,6 +1185,9 @@ def _installation(value: Dict[str, Any]) -> List[Dict[str, str]]:
1156
1185
  if _same(_get(secret, "agentRef"), _get(pkg, "agentRef")) and _same(_context_of(secret), context) and not _contains(declared, _get(secret, "slot")):
1157
1186
  flag075("/record/machines/%d/secrets/%d/slot" % (machine_index, index),
1158
1187
  "the package declares no slot %s: a secret goes only where a placed package declares it" % _show(_get(secret, "slot")))
1188
+ if placement is not None and not _scope_admits(record, _get(placement, "publisherKid"), context):
1189
+ _finding(findings, "FAC-SEM-122")("/record/machines/%d/packages/%d/publisherKid" % (machine_index, placement_index),
1190
+ "publisher key %s is a partner's, not trusted in %s" % (_show(_get(placement, "publisherKid")), _show(context)))
1159
1191
  if placement is not None:
1160
1192
  flag111 = _finding(findings, "FAC-SEM-111")
1161
1193
  for suffix, message in _placement_grant_problems(record, placement):
@@ -1356,7 +1356,7 @@ 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",), "enroll-token": ("1",), "entitlement": ("1",),
1359
+ "access-log": ("1",), "approval": ("1",), "activity-summary": ("1",), "coding-agents": ("1",), "enroll-token": ("1",), "entitlement": ("1",),
1360
1360
  "entitlement-key-set": ("1",), "estate-record": ("1",), "execution-packet": ("1",),
1361
1361
  "fabric-activity": ("0.1",), "fabric-dashboard": ("0.1",), "fabric-delegation": ("0.1",), "fabric-device": ("0.1",),
1362
1362
  "fabric-interop": ("0.1", "0.2"), "fabric-local-channel": ("0.1",), "fabric-local-runner": ("0.1",),
@@ -1364,7 +1364,7 @@ PROTOCOL_REVISIONS: Dict[str, Tuple[str, ...]] = {
1364
1364
  "fabric-provider": ("0.1",), "fabric-relay": ("0.1",), "fabric-secret-envelope": ("1",), "fabric-service": ("0.1",),
1365
1365
  "fabric-settings-backup": ("1",), "heartbeat": ("1",), "learning-note": ("1",), "memory": ("0.1",), "pairing-message": ("1",), "pipeline": ("0.1",),
1366
1366
  "revocation-delta": ("1",), "session-handoff": ("1",), "share-registration": ("1",),
1367
- "share-registration-receipt": ("1",), "window-lease": ("1",),
1367
+ "share-registration-receipt": ("1",), "support-quality-daily": ("1",), "window-lease": ("1",),
1368
1368
  }
1369
1369
  MAX_PROTOCOLS = 64
1370
1370
  _REVISION = re.compile(r"([a-z][a-z0-9.-]{0,63})/([0-9]{1,4}(?:\.[0-9]{1,4}){0,2})", re.ASCII)
@@ -1622,9 +1622,13 @@ def make_usage_receipt(provider: str, model: str, *, input_tokens: int, output_t
1622
1622
  cache_read_tokens: int = 0, cache_write_tokens: int = 0,
1623
1623
  cost_usd: Optional[float] = None, cost_basis: Optional[str] = None,
1624
1624
  at: Optional[str] = None, route: Optional[str] = None,
1625
- project_id: Optional[str] = None, task_run_id: Optional[str] = None) -> Dict[str, Any]:
1625
+ project_id: Optional[str] = None, task_run_id: Optional[str] = None,
1626
+ tokens_measured: bool = True) -> Dict[str, Any]:
1626
1627
  """One model call, from the provider's own usage numbers. No prompt, output or caller.
1627
1628
 
1629
+ `tokens_measured=False` marks a call whose token counts are a lower bound or unknown (the provider returned none,
1630
+ the stream was cut): the report counts it in `unmeasuredCalls` and its token totals are a lower bound (DEC-0091).
1631
+
1628
1632
  `project_id` (a URI, kept in its normal form, DEC-0076) and `task_run_id` (the service's own run id) attribute the
1629
1633
  call to the project the service ran it for (DEC-0065); they never name a caller, session or person.
1630
1634
 
@@ -1650,6 +1654,10 @@ def make_usage_receipt(provider: str, model: str, *, input_tokens: int, output_t
1650
1654
  raise ServiceError("route is subscription, key or platform-metered (DEC-0046, DEC-0061).")
1651
1655
  receipt = {"at": at or now_iso(), "provider": provider, "model": model, **counts,
1652
1656
  "costUsd": None if cost_usd is None else float(cost_usd), "costBasis": basis}
1657
+ if tokens_measured is not True:
1658
+ if tokens_measured is not False:
1659
+ raise ServiceError("tokens_measured is True or False.")
1660
+ receipt["tokensMeasured"] = False
1653
1661
  if route is not None:
1654
1662
  receipt["route"] = route # the operator's own login in its vendor's runner, an issued key, or the platform's metered models
1655
1663
  if task_run_id is not None and project_id is None:
@@ -1703,6 +1711,8 @@ def _by_project(receipts: List[Dict[str, Any]], first: str, last: str) -> List[D
1703
1711
 
1704
1712
  def _add_totals(row: Dict[str, Any], receipt: Dict[str, Any]) -> None:
1705
1713
  row["calls"] += 1
1714
+ if receipt.get("tokensMeasured") is False:
1715
+ row["unmeasuredCalls"] = row.get("unmeasuredCalls", 0) + 1 # written only when above 0: absent is 0 (DEC-0091)
1706
1716
  for name in ("inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"):
1707
1717
  row[name] += int(receipt.get(name) or 0)
1708
1718
  if receipt.get("costUsd") is None:
@@ -1760,6 +1770,8 @@ def _priced_row(row: Dict[str, Any], at: str) -> List[Dict[str, str]]:
1760
1770
  flag = lambda path, message: [{"code": "FAC-SEM-025", "instancePath": path, "message": message}] # noqa: E731
1761
1771
  if unpriced > calls:
1762
1772
  return flag(at + "/unpricedCalls", "more unpriced calls than calls")
1773
+ if _usage_count(row.get("unmeasuredCalls")) > calls: # DEC-0091: unknown token counts are some of the calls
1774
+ return flag(at + "/unmeasuredCalls", "more calls with unknown tokens than calls")
1763
1775
  if calls > 0 and unpriced == calls and row.get("costUsd", _MISSING_COST) is not None:
1764
1776
  return flag(at + "/costUsd", "every call is unpriced, so the cost is unknown (null), not a number")
1765
1777
  if unpriced < calls and row.get("costUsd", _MISSING_COST) is None:
@@ -1800,7 +1812,7 @@ def usage_findings(report: Any) -> List[Dict[str, str]]:
1800
1812
  if spent:
1801
1813
  flag(at + "/byModel", "a day with calls, tokens or cost names the models they belong to")
1802
1814
  continue
1803
- for field in ("calls", "unpricedCalls", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"):
1815
+ for field in ("calls", "unpricedCalls", "unmeasuredCalls", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"):
1804
1816
  total = sum(m.get(field) or 0 for m in models if isinstance(m.get(field) or 0, (int, float)))
1805
1817
  if (day.get(field) or 0) != total:
1806
1818
  flag("%s/%s" % (at, field), "the day's %s is not the sum of its models (%s ≠ %s)" % (field, day.get(field) or 0, total))
@@ -2001,6 +2013,8 @@ def usage_report(receipts: Iterable[Dict[str, Any]], *, service_id: str, instanc
2001
2013
  for m in models:
2002
2014
  for name in ("calls", "unpricedCalls", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"):
2003
2015
  day[name] += m[name]
2016
+ if m.get("unmeasuredCalls"):
2017
+ day["unmeasuredCalls"] = day.get("unmeasuredCalls", 0) + m["unmeasuredCalls"]
2004
2018
  day["_cost"] += m["costUsd"] or 0.0
2005
2019
  day = _close_totals(day)
2006
2020
  day["byModel"] = models
@@ -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.38.0"
8
+ version: "0.39.0"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "07042181bfae2a481aa0da1a5651f24c76669c48"
10
+ contract-commit: "c55f2d5a11c9a0197e958e87212fca7ae1154769"
11
11
  ---
12
12
 
13
13
  # Creating Fabric-compatible agents
@@ -141,7 +141,7 @@ python3 <plugin-dir>/skills/adapting-projects-to-fabric/scripts/adapt_project.py
141
141
  The effect class is declared, never defaulted: ask the author what the agent does outside itself when the request
142
142
  does not say. Cost presets are only for a run whose cost varies; their amounts come from the author or measured runs.
143
143
 
144
- Pin exactly contract `0.1.0` at commit `07042181bfae2a481aa0da1a5651f24c76669c48` and
144
+ Pin exactly contract `0.1.0` at commit `c55f2d5a11c9a0197e958e87212fca7ae1154769` and
145
145
  read the pinned guide before implementing protocol details. If this skill is installed
146
146
  without its sibling, the scaffolder is absent: create the bundle by hand from the pinned
147
147
  contract's `docs/guides/connecting-compatible-agents.md` and mark the structural check
@@ -30,7 +30,8 @@ The normative rules are the Fabric Agent Contract's, at the commit this plugin p
30
30
 
31
31
  Every step up is paid as cloud only: the person or the organization pays for the cloud its agents run in, and the
32
32
  order says which hosting it holds (`personal-cloud`, `org-cloud`, `enterprise-cloud` in `entitlement/1`). An agent never
33
- checks a price; it may read whether its context's entitlement is current and degrade honestly when it is not.
33
+ checks a price; it may read whether its context's entitlement is current and degrade honestly when it is not. An agent placed
34
+ into a customer's cloud is part of that customer's tier, not a product of its own.
34
35
 
35
36
  ## Contexts: one agent, the person and each organization
36
37
 
@@ -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.38.0"
22
+ version: "0.39.0"
23
23
  ---
24
24
 
25
25
  # Working with Fabric
@@ -65,7 +65,7 @@ WATCH_MIN_INTERVAL_S = 10
65
65
  WATCH_MAX_BACKOFF_S = 900
66
66
  SESSION_ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
67
67
  LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{1,5})")
68
- VERSION = "0.38.0"
68
+ VERSION = "0.39.0"
69
69
  HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code",
70
70
  "hermes": "Hermes", "opencode": "OpenCode", "openclaw": "OpenClaw", "pi": "Pi"}
71
71
  NATIVE_PLUGIN_HOSTS = frozenset(("hermes", "opencode", "openclaw", "pi"))