@passioncode-ai/fabric-agent-adapter 0.35.4 → 0.37.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 (31) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +45 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +37 -1
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +13 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +129 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +67 -4
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +252 -5
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +5 -4
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +4 -3
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +109 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +194 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +82 -4
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +356 -9
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +10 -4
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -4
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +173 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/share-registration.schema.json +218 -0
  25. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +22 -5
  26. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +105 -0
  27. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/orchestration-and-learning.md +100 -0
  28. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/practices.md +103 -0
  29. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +112 -0
  30. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  31. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,63 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.37.0 - 2026-10-19
4
+
5
+ ### Added
6
+
7
+ - **`creating-fabric-agents` carries what the estate learned about building agents** (FAA-58, the operator's request
8
+ of 2026-10-19): four new references every new agent starts from.
9
+ - `where-agents-run.md`: this machine, a personal cloud, an organization, Enterprise; contexts (`personal`,
10
+ `org:<id>`) with one instance per context and no secret, spend or journal crossing; connecting a machine (nearby
11
+ code, headless enroll token); sharing an agent into an organization; running while the laptop is closed; selling
12
+ an agent (a paid agent in its developer's organization, a partner's setup).
13
+ - `models-and-providers.md`: lanes with a fallback of another vendor and an open-vendor model where users' content is
14
+ read; a dated starting choice from the OpenRouter catalog; several kinds of input and output; budgets that survive
15
+ reasoning; one OpenRouter key per agent and context with its ceiling, read from the environment; measuring a choice.
16
+ - `orchestration-and-learning.md`: workflow first; the work graph of durable jobs with intent keys, effects observed,
17
+ limits as pauses (DEC-0087); calling other agents within a narrower grant and budget; stops for a person; learning
18
+ loops (decisions with reasons, one trace per turn, failures as planted fixtures first, history replayed, learnings
19
+ proposed and never self-approved); the loop guard.
20
+ - `practices.md`: the rules production agents taught us, each with its check: judges and evals, numbers, money,
21
+ people in the loop, reliability, shipping.
22
+ - The intake grill asks **Where does it run, for whom?**, **Which models?**, **Who pays?** and **How does it learn?**;
23
+ the knowledge intake links the four references. `test/validate.py` holds the rows, the links and each reference's
24
+ checklist (`KnowledgePackTests`); eval scenario `agent-for-a-personal-and-an-organization-cloud`; three trigger
25
+ queries.
26
+
27
+ ## 0.36.0 - 2026-10-19
28
+
29
+ Repinned to Fabric Agent Contract `07042181bfae2a481aa0da1a5651f24c76669c48` (DEC-0084 to DEC-0088); `d8146cf` stays
30
+ supported, so bundles issued under it stay valid. DEC-0085 and DEC-0088 (entitlements) are the platform's and the
31
+ organization server's, not the kits'.
32
+
33
+ ### Added
34
+
35
+ - **Jobs under limits and uncertain effects, in both interop kits** (FAA-56, contract DEC-0087, FAC-SEM-119/120): a job a
36
+ limit paused stays `working` with `quota-exhausted until <instant>` (`jobs.pause`); a job a limit stopped ends
37
+ `quota-exhausted` with `data: {retryable, resetsAt?}` (`jobs.quota_exhausted`); an effect nobody saw ends
38
+ `outcome-unknown` (`jobs.outcome_unknown`). Idempotency keys are kept per authenticated caller and a repeated key is
39
+ answered by its job (`jobs.repeat`): a limited job runs again once the limit no longer holds, an `outcome-unknown` one
40
+ never. Usage nobody measured is `null` only toward a caller that names `fabric-interop/0.2` (`usage_toward`). As a
41
+ caller: `request_meta`, `job_caller_action`, `intent_idempotency_key`. Held to the contract's `job-limits.json`.
42
+ - **Share grants into an organization** (contract DEC-0086): new `fabric_share.py` / `fabric-share.mjs` with FAC-SEM-117
43
+ (a share grant id, a millisecond window, one agentRef kept against the parent revision), FAC-SEM-118 (a
44
+ `share-registration/1` document) and FAC-SEM-045's `grant.usage` context clause; `share-registration.schema.json`
45
+ vendored self-contained.
46
+ - **The estate ceremonies** (contract DEC-0084): in both estate kits FAC-SEM-114 (what a revision drops it revokes in the
47
+ same revision), FAC-SEM-073 (a device key in canonical base64), FAC-SEM-076 (a key reference
48
+ `<kid>@sha256:<hex>`), `device_key_fingerprint`, `device_key_ref`, and `verify_estate_record(..., parent=)` runs
49
+ FAC-SEM-114. New `fabric_ceremonies.py` / `fabric-ceremonies.mjs`: `verify_enroll_token`, `enroll_join_input`,
50
+ FAC-SEM-113. Pairing and the hub's single use of a token follow in FAA-57.
51
+ - **A partner's setup for a company** (FAA-55, the partner channel's PA-07): documented and tested as an org skill set
52
+ whose `requires.packages` names the agents, every package signed with the partner's key. No new format.
53
+
54
+ ### Changed
55
+
56
+ - **FAC-SEM-034** in both profile kits compares the probes before it builds a list and names at most 64 candidates, so a
57
+ huge index allocates nothing (the contract's fix of this repository's report).
58
+ - **The protocol register** in both service kits matches the contract's: `enroll-token`, `entitlement`,
59
+ `entitlement-key-set`, `pairing-message`, `share-registration`, `share-registration-receipt`, and `fabric-interop/0.2`.
60
+
3
61
  ## 0.35.4 - 2026-10-19
4
62
 
5
63
  ### Changed
package/README.md CHANGED
@@ -254,7 +254,7 @@ Scaffolding is non-destructive by default. It writes only the paths declared in
254
254
  The default pin for new bundles is [`fabric-contract.lock.json`](fabric-contract.lock.json):
255
255
 
256
256
  - version: `0.1.0`
257
- - commit: `d8146cfb0a1c74fc6432284c4356bb0c864b37b5`
257
+ - commit: `07042181bfae2a481aa0da1a5651f24c76669c48`
258
258
 
259
259
  Every other mention of the default contract revision — this section, the skills' metadata, the
260
260
  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.35.4",
3
+ "version": "0.37.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.35.4",
6
+ "version": "0.37.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.35.4"
8
+ version: "0.37.0"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "d8146cfb0a1c74fc6432284c4356bb0c864b37b5"
10
+ contract-commit: "07042181bfae2a481aa0da1a5651f24c76669c48"
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 `d8146cfb0a1c74fc6432284c4356bb0c864b37b5`.
73
+ - commit `07042181bfae2a481aa0da1a5651f24c76669c48`.
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 = "d8146cfb0a1c74fc6432284c4356bb0c864b37b5"
24
+ CONTRACT_COMMIT = "07042181bfae2a481aa0da1a5651f24c76669c48"
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, "82e163665e156a08aa3b92e4711015582c06162c", "1636fa97262f4a26e2086572d540c2ee701e0010", "f00a7685e3861c27f7899c033fb850a145a1708f", "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b", "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0", "b291bf09e0a9f9fd3c16cf540832cd021203b7d1", "0cacd08b0e95b6abf131eae1206b60792138bc3c", "2eb91c00e852d9c31d804310d38fad91589fcbac", "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5", "a83bec6b47776af688402c18efec507143061f68", "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
27
+ SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "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.35.4"
21
+ version: "0.37.0"
22
22
  contract-version: "0.1.0"
23
23
  extension: "fabric-service/0.1"
24
- extension-commit: "d8146cfb0a1c74fc6432284c4356bb0c864b37b5"
24
+ extension-commit: "07042181bfae2a481aa0da1a5651f24c76669c48"
25
25
  ---
26
26
 
27
27
  # Building Fabric services
@@ -79,7 +79,8 @@ MCP registration into a client config, the login flow or the CSRF header.
79
79
 
80
80
  When other agents or Fabric call the service over MCP, it follows `fabric-interop/0.1`:
81
81
  each manifest capability is the tool of its name, long work is a job with a stable id,
82
- a question for a person is an elicitation, and every call carries one trace. Read
82
+ a question for a person is an elicitation, and every call carries one trace. A job a limit stops or pauses, and an
83
+ effect nobody saw, have their own codes and a key per caller (DEC-0087). Read
83
84
  [the interop reference](references/interop.md) before writing an MCP tool; the helpers
84
85
  are in [`scripts/fabric_interop.py`](scripts/fabric_interop.py) and
85
86
  [`scripts/fabric-interop.mjs`](scripts/fabric-interop.mjs). When the callers are agents of the estate, the
@@ -87,7 +88,9 @@ service checks each call's delegation token (DEC-0039): read [the delegation ref
87
88
  the verifier is [`scripts/fabric_delegation.py`](scripts/fabric_delegation.py) /
88
89
  [`scripts/fabric-delegation.mjs`](scripts/fabric-delegation.mjs). A service on the operator's machines that
89
90
  applies the estate record (DEC-0055) verifies each revision first and trusts only the keys it certifies, and runs an
90
- agent once per context, the person's or an organization's (DEC-0082): read
91
+ agent once per context, the person's or an organization's (DEC-0082), and checks a share grant into an organization
92
+ with [`scripts/fabric_share.py`](scripts/fabric_share.py) (DEC-0086); a revision's ceremonies and a headless
93
+ machine's enroll token are in [`scripts/fabric_ceremonies.py`](scripts/fabric_ceremonies.py) (DEC-0084): read
91
94
  [the estate record reference](references/estate-record.md). When Fabric chooses the service's runner and models
92
95
  (DEC-0046), or a project routes its work through an ordered list of runners (DEC-0026: the doctor's closed probe
93
96
  results, the route checks FAC-SEM-028…034), read [the profiles reference](references/profiles.md).
@@ -18,6 +18,8 @@ derived from its own digest. Python: [`scripts/fabric_estate.py`](../scripts/fab
18
18
  - Applying a revision
19
19
  - Trusting a delegation key
20
20
  - Contexts: one agent for you and for an organization
21
+ - The estate ceremonies
22
+ - Share grants into an organization
21
23
  - Checking a record you produce
22
24
  - What the kit does not do
23
25
 
@@ -116,6 +118,49 @@ Every rule is total on hostile JSON, as the contract's are: an `instancePath` na
116
118
  non-object before it does not shift it), and a value whose own `toString` member is data is shown as its JSON. Both kits
117
119
  answer the contract's contexts fixtures and variants of them (`test/fixtures/contract/estate-vectors.json`).
118
120
 
121
+ ## The estate ceremonies
122
+
123
+ Contract DEC-0084: create a network, add a machine nearby (a six-digit comparison code) or headless (an enroll token),
124
+ remove a machine, replace a machine's key. Fabric runs the ceremonies and signs every revision with the person's
125
+ passkey; what a service applying the record checks is in the record itself:
126
+
127
+ - **What a revision drops, it revokes in the same revision (FAC-SEM-114).** A removed machine is listed as
128
+ `machine <id>` in `revocations`, and its hub key as `hub-key <kid>`; a replaced device key is revoked by its key
129
+ reference, `<kid>@sha256:<hex of its octets>` (`device_key_ref` / `deviceKeyRef`), so the new key under the same kid
130
+ stays usable; a hub key certified anew takes a new kid. `verify_estate_record(record, at=..., parent=previous)` runs it
131
+ when you pass the parent; `estate_record_rules("estate-record-revision", {"parent", "next"})` alone.
132
+ - **A device key's octets are canonical padded base64 (FAC-SEM-073),** the one form a key reference can name
133
+ (`device_key_fingerprint`), and a `device-key` revocation that holds `@` is a well-formed key reference (FAC-SEM-076).
134
+ - **A headless machine joins with an `enroll-token/1`** (`fabric_ceremonies.py`, `fabric-ceremonies.mjs`). Read the token
135
+ from stdin or a file only your user can read, never from argv, a URL or a log. `verify_enroll_token(token, record=,
136
+ genesis=, at=)` checks it against the revision you applied and revision 1's digest; sign `enroll_join_input(token)`
137
+ with the machine's `#sig` key and present that proof with the token. The token gives the machine no trust: only the
138
+ passkey-signed revision that lists it does. `enroll_token_rules("enroll-token", token)` is FAC-SEM-113.
139
+
140
+ The nearby pairing (`pairing-message/1`, the comparison code, the rendezvous) and the hub's single use of a token are
141
+ Project Observatory's node and Fabric's hub; the kits port them in a later version (FAA-57). The relay's admission and
142
+ session checks belong to the relay; the kits do not run a relay.
143
+
144
+ ## Share grants into an organization
145
+
146
+ Contract DEC-0086. A grant whose `context` is an organization's (`org:<orgId>`) is a share grant, and the organization
147
+ server joins every `grant.usage` to the agent it runs through a share registration the sharer's node sends. Before you
148
+ sign a revision that places one, run `fabric_share.py` (`fabric-share.mjs`):
149
+
150
+ - `share_rules("org-share-grants", {"record": next, "parent": previous})`: FAC-SEM-117. The grant's `meta.id` is
151
+ `urn:fabric:share:` and 32 lower-case hex digits from a CSPRNG (`secrets.token_hex(16)`), never a name, an address or an
152
+ estate id; its window carries at most three fraction digits; the placements naming it run one `agentRef`; and against
153
+ `parent`, the latest earlier revision that placed it, that `agentRef` never changes. To move a share to another agent,
154
+ revoke the grant and mint a new id.
155
+ - `share_rules("grant", grant)`: FAC-SEM-110 and 117 on one grant.
156
+ - `share_rules("share-registration", doc)`: FAC-SEM-118 on a `share-registration/1` document you read or log.
157
+ - `share_rules("device-attribution", {binding, batch})`: FAC-SEM-045's DEC-0086 clause, for a collector that sends
158
+ `grant.usage`: each carries its grant's own context, `org:` and the certificate's `org.id` as written.
159
+
160
+ `share-registration.schema.json` beside the kit is the contract's schema with `shareGrantId`, `orgContextId`,
161
+ `sha256` and `agentRef` brought in, so it validates offline. Deriving, sending, deciding and joining registrations are the
162
+ sharer's node's and the organization server's, not this kit's.
163
+
119
164
  ## Checking a record you produce
120
165
 
121
166
  `estate_record_rules(kind, value)` / `estateRecordRules(kind, value)` are the contract's rules, finding for finding:
@@ -23,6 +23,12 @@ The interop block sits in the manifest under the capability's `extensions`:
23
23
 
24
24
  That key has one spelling (the contract's `src/extensions.ts`); copy it, never retype it.
25
25
 
26
+ ## Contents
27
+
28
+ - [The kit](#the-kit)
29
+ - [Limits and uncertain effects](#limits-and-uncertain-effects)
30
+ - [What the probe checks](#what-the-probe-checks)
31
+
26
32
  ## The kit
27
33
 
28
34
  ```python
@@ -41,7 +47,8 @@ response = server.handle(json_rpc_message) # POST /mcp body in, JSON re
41
47
  stops for a person. Fabric answers through `fabric.job.get` with `inputResponses`; the
42
48
  server calls `on_input(job_id, answers, ctx)` with the answers that matched.
43
49
  - `jobs.complete(job_id, fi.result_envelope(outcome=..., producer=..., ...))`,
44
- `jobs.fail(job_id, code, message)`, `jobs.cancel(job_id)`. A terminal job never changes
50
+ `jobs.fail(job_id, code, message, data=None)`, `jobs.cancel(job_id)`; a limit or an unseen effect has its own calls
51
+ ([Limits and uncertain effects](#limits-and-uncertain-effects)). A terminal job never changes
45
52
  again. A result with unverified claims is `partial`, never `succeeded`.
46
53
  - The envelope's `trace` is authoritative for a stored result (DEC-0017): `complete` fills
47
54
  it with the span of the call that started the job and refuses one naming another span,
@@ -77,6 +84,35 @@ claude -p "Reply with the single word OK." --strict-mcp-config --mcp-config "$TM
77
84
 
78
85
  The first `system/init` event lists `mcp_servers` (`connected`) and the `mcp__<server>__*` tools.
79
86
 
87
+ ## Limits and uncertain effects
88
+
89
+ Contract DEC-0087 (`FAC-SEM-119`, `FAC-SEM-120`). A limit and an effect nobody saw are not ordinary failures: a caller
90
+ that submits again pays twice. Both kits (`fabric_interop.py`, `fabric-interop.mjs`) carry the contract's functions,
91
+ answer for answer (`test/fixtures/contract/job-limit-vectors.json`).
92
+
93
+ As a provider:
94
+
95
+ - **A limit paused the job and you will resume it:** `jobs.pause(job_id, resets_at=..., text=...)` keeps it `working`
96
+ with `statusMessage` `quota-exhausted until <UTC instant>: <text>` and sets `pollIntervalMs` to reach the reset,
97
+ at most an hour. An instant with an offset, more than three fraction digits or a day that does not exist is refused.
98
+ - **A limit stopped it and you will not resume it:** `jobs.quota_exhausted(job_id, message, retryable, resets_at=None)`
99
+ ends it `failed` with `quota-exhausted` and `data: {retryable, resetsAt?}`; `resetsAt` only with `retryable: True`.
100
+ - **The effect may have happened and you cannot see whether:** `jobs.outcome_unknown(job_id, message, data)`. Its key
101
+ never runs again; only an observed result settles it.
102
+ - **Keys per caller.** `jobs.create(..., caller=<authenticated caller>, idempotency_key=<key>)` keeps the key for that
103
+ caller only. A key that already names a job starts nothing; `jobs.repeat(caller, key, now)` answers `new`, `same`
104
+ (answer with that job) or `run`: a `quota-exhausted` job runs again once its reset passed or you pass
105
+ `limit_holds=False`, a `limit-reached` job once `approval_granted=True`, and an `outcome-unknown` job never.
106
+ - **Usage you did not measure is `null`, never 0,** but only toward a caller whose request `_meta.protocols` names
107
+ `fabric-interop/0.2`: pass `request_meta` to `result_envelope` (`usage_toward`). Toward any other caller the kit
108
+ writes the measured lower bound, as before.
109
+
110
+ As a caller: send `request_meta(traceparent)` as your `_meta` (it names `fabric-interop/0.1` and `0.2`), act on every
111
+ `fabric.job.get` answer as `job_caller_action(answer, now)` says, and resend only under
112
+ `intent_idempotency_key({agentId, capability, input})`, never a new key. Node names are the camelCase twins
113
+ (`jobs.quotaExhausted(id, message, {retryable, resetsAt})`, `jobCallerAction`, `intentIdempotencyKey`). The guidance
114
+ for an agent's own design is in `creating-fabric-agents/references/resilience-and-autonomy.md`.
115
+
80
116
  ## What the probe checks
81
117
 
82
118
  `check_service.py` adds these rules; they read, and never call a capability.
@@ -12,6 +12,7 @@ Contract DEC-0050 (format and digest), DEC-0051 (signatures), DEC-0071 and DEC-0
12
12
  - [sign](#sign)
13
13
  - [verify](#verify)
14
14
  - [A consumer agent](#a-consumer-agent)
15
+ - [A partner's setup for a company](#a-partners-setup-for-a-company)
15
16
  - [What this version does not do](#what-this-version-does-not-do)
16
17
 
17
18
  ## What a package is
@@ -92,6 +93,18 @@ by role and tools. A role names the secret slots that configure its server (`sec
92
93
  `secrets[]`, one slot to one role. Before installing, `consumer_placement(pkg, configured)` / `consumerPlacement(pkg,
93
94
  Map)` names each role the placement does not register and each tool its server lacks.
94
95
 
96
+ ## A partner's setup for a company
97
+
98
+ A partner who builds agents for a company ships them without a new format (FAA-55, the partner channel's PA-07). Each
99
+ agent is its own `kind: agent` package. The setup is one `kind: skill-set` package at `layer: org`: the company's own
100
+ skills (house style, procedures), with `requires.packages` naming every agent it sets up by `publisher`, `id` and a
101
+ version `range`. The partner packs and signs every package with its own publisher key (`sign --key`); the company's
102
+ machine pins that key and `verify` refuses a package signed by any other. One signed package serves every company: what
103
+ differs per company (a display name, a brand, an address) is placement configuration, never a second package.
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).
107
+
95
108
  ## What this version does not do
96
109
 
97
110
  - **ES256.** It waits for Fabric's decision D2; until then a verifier refuses it as `unknown-algorithm` (FAA-29).
@@ -1,6 +1,6 @@
1
1
  # fabric-service/0.1 — wire reference
2
2
 
3
- Pinned to `fabric-agent-contract` commit `d8146cfb0a1c74fc6432284c4356bb0c864b37b5`
3
+ Pinned to `fabric-agent-contract` commit `07042181bfae2a481aa0da1a5651f24c76669c48`
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;
@@ -0,0 +1,129 @@
1
+ // The estate ceremonies' headless join (contract DEC-0084), the Node kit; the twin of fabric_ceremonies.py, answer for
2
+ // answer (test/fixtures/contract/ceremony-vectors.json). Node built-ins only.
3
+ // verifyEnrollToken (the hub's and the node's verifier of an enroll-token/1), enrollJoinInput (what the joining machine
4
+ // signs with its #sig key) and enrollTokenRules (FAC-SEM-113). A token gives the machine no trust: only a passkey-signed
5
+ // revision that lists it does. Read it from stdin or a 0600 file, never argv, a URL or a log. Every function answers and
6
+ // never throws on malformed input. Normative source: the contract's src/enroll-token.ts and src/signing-challenges.ts.
7
+ // #region ceremonies-kit-node, docs: plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md#the-estate-ceremonies
8
+
9
+ import crypto from 'node:crypto';
10
+ import { jcs } from './fabric-delegation.mjs';
11
+ import { CLOCK_SKEW_SECONDS, estateTime, isSigningRpId, originWithin, verifyAssertion } from './fabric-estate.mjs';
12
+
13
+ export const ENROLL_TOKEN_SCHEMA = 'enroll-token/1';
14
+ export const ENROLL_TOKEN_TYPE = 'enroll-token/1';
15
+ export const ENROLL_JOIN_TYPE = 'enroll-join/1';
16
+ export const ENROLL_PURPOSE = 'agent-host';
17
+ export const MAX_ENROLL_TOKEN_SECONDS = 900;
18
+ export const ENROLL_TOKEN_FIELDS = Object.freeze(['scope', 'genesis', 'purpose', 'ceiling', 'notBefore', 'expiresAt', 'nonce', 'uses']);
19
+ const SHA256 = /^sha256:[a-f0-9]{64}$/;
20
+ const ESTATE_SCOPE = /^estate:[A-Za-z0-9][A-Za-z0-9._:~-]{0,127}$/;
21
+ const NONCE32 = /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/;
22
+ const ASSERTION_MEMBERS = 'alg,authenticatorData,clientDataJSON,credentialId,format,sig';
23
+
24
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
25
+ const isString = (value) => typeof value === 'string';
26
+ const matches = (pattern, value) => isString(value) && pattern.test(value);
27
+ const list = (value) => (Array.isArray(value) ? value.filter(isObject) : []);
28
+ const show = (value) => {
29
+ if (typeof value === 'string') return value;
30
+ try { return String(value); } catch { return JSON.stringify(value) ?? typeof value; }
31
+ };
32
+ const REGISTER = new Map([
33
+ [ENROLL_TOKEN_TYPE, { fields: ENROLL_TOKEN_FIELDS, check: (f) => matches(ESTATE_SCOPE, f.scope) && matches(SHA256, f.genesis) && f.purpose === ENROLL_PURPOSE
34
+ && (f.ceiling === null || matches(SHA256, f.ceiling)) && Number.isFinite(estateTime(f.notBefore)) && Number.isFinite(estateTime(f.expiresAt)) && matches(NONCE32, f.nonce) && f.uses === 1 }],
35
+ [ENROLL_JOIN_TYPE, { fields: ['digest', 'nonce'], check: (f) => matches(SHA256, f.digest) && matches(NONCE32, f.nonce) }],
36
+ ]);
37
+
38
+ /** The bytes of a registered statement (DEC-0063), for enroll-token/1 and enroll-join/1; undefined otherwise. */
39
+ export function signingStatement(type, fields) {
40
+ const spec = REGISTER.get(type);
41
+ if (!spec || !isObject(fields)) return undefined;
42
+ const names = Object.keys(fields);
43
+ if (names.length !== spec.fields.length || !spec.fields.every((name) => Object.hasOwn(fields, name)) || !spec.check(fields)) return undefined;
44
+ try { return Buffer.from(jcs({ ...fields, type }), 'utf8'); } catch { return undefined; }
45
+ }
46
+
47
+ function statementFields(token) {
48
+ if (!isObject(token) || token.schema !== ENROLL_TOKEN_SCHEMA || !isObject(token.statement) || token.statement.type !== ENROLL_TOKEN_TYPE) return undefined;
49
+ // The token holds exactly the schema's members: nothing unsigned rides beside the statement (DEC-0084).
50
+ if (Object.keys(token).sort().join() !== 'schema,signature,statement') return undefined;
51
+ const { type: _type, ...fields } = token.statement;
52
+ return signingStatement(ENROLL_TOKEN_TYPE, fields) ? fields : undefined;
53
+ }
54
+
55
+ /** The reference verifier of an enroll-token/1 under trust {record, genesis, at: Date}; the first failure answers. */
56
+ export function verifyEnrollToken(token, trust) {
57
+ const at = isObject(trust) && trust.at instanceof Date ? trust.at.getTime() : Number.NaN;
58
+ if (!Number.isFinite(at)) return { ok: false, problem: 'no-clock' };
59
+ const fields = statementFields(token);
60
+ const record = trust.record;
61
+ if (!fields || !isObject(record) || !isString(record.estate)) return { ok: false, problem: 'malformed' };
62
+ const { signature } = token;
63
+ if (!isObject(signature) || Object.keys(signature).sort().join() !== ASSERTION_MEMBERS || signature.format !== 'webauthn-assertion' || !isString(signature.credentialId)) return { ok: false, problem: 'malformed' };
64
+ if (fields.scope !== `estate:${record.estate}` || fields.genesis !== trust.genesis) return { ok: false, problem: 'wrong-estate' };
65
+ const authorities = list(record.authorities);
66
+ if (authorities.length === 0) return { ok: false, problem: 'no-trusted-authority' };
67
+ if (list(record.revocations).some((entry) => entry.kind === 'authority' && entry.ref === signature.credentialId)) return { ok: false, problem: 'revoked-authority' };
68
+ const found = authorities.filter((entry) => entry.credentialId === signature.credentialId);
69
+ if (found.length > 1) return { ok: false, problem: 'duplicate-authority' };
70
+ const authority = found[0];
71
+ if (!authority) return { ok: false, problem: 'unknown-authority' };
72
+ const rpId = show(authority.rpId);
73
+ if (!isSigningRpId(rpId)) return { ok: false, problem: 'wrong-rp' };
74
+ const credential = {
75
+ credentialId: signature.credentialId, alg: show(authority.alg), publicKey: isObject(authority.publicKey) ? authority.publicKey : {}, rpId,
76
+ origins: Array.isArray(authority.origins) ? authority.origins.filter((origin) => isString(origin) && originWithin(origin, rpId)) : [],
77
+ };
78
+ const challenge = crypto.createHash('sha256').update(signingStatement(ENROLL_TOKEN_TYPE, fields)).digest();
79
+ const assertion = verifyAssertion(signature, credential, challenge);
80
+ if (!assertion.ok) return { ok: false, problem: assertion.problem };
81
+ const notBefore = estateTime(fields.notBefore);
82
+ const expiresAt = estateTime(fields.expiresAt);
83
+ if (expiresAt <= notBefore || expiresAt - notBefore > MAX_ENROLL_TOKEN_SECONDS * 1000) return { ok: false, problem: 'too-long-lived' };
84
+ if (at < notBefore - CLOCK_SKEW_SECONDS * 1000) return { ok: false, problem: 'not-yet-valid' };
85
+ if (at > expiresAt) return { ok: false, problem: 'expired' };
86
+ return { ok: true, nonce: fields.nonce, expiresAt: fields.expiresAt, signedBy: credential.credentialId };
87
+ }
88
+
89
+ /** The enroll-join/1 statement the joining machine signs with its #sig key; undefined without a register statement. */
90
+ export function enrollJoinInput(token) {
91
+ const fields = statementFields(token);
92
+ const statement = fields && signingStatement(ENROLL_TOKEN_TYPE, fields);
93
+ if (!fields || !statement) return undefined;
94
+ return signingStatement(ENROLL_JOIN_TYPE, { digest: `sha256:${crypto.createHash('sha256').update(statement).digest('hex')}`, nonce: fields.nonce })?.toString('utf8');
95
+ }
96
+
97
+ function tokenFindings(value) {
98
+ const findings = [];
99
+ const flag = (instancePath, message) => findings.push({ code: 'FAC-SEM-113', instancePath, message });
100
+ const statement = isObject(value.statement) ? value.statement : undefined;
101
+ if (!statement) {
102
+ flag('/statement', 'an enroll token carries its signed statement');
103
+ return findings;
104
+ }
105
+ if (statement.type !== ENROLL_TOKEN_TYPE) flag('/statement/type', "the statement's type is enroll-token/1, the register's (DEC-0063)");
106
+ const extra = Object.keys(statement).filter((name) => name !== 'type' && !ENROLL_TOKEN_FIELDS.includes(name));
107
+ const missing = ENROLL_TOKEN_FIELDS.filter((name) => !Object.hasOwn(statement, name));
108
+ if (extra.length > 0 || missing.length > 0) flag('/statement', `the statement has exactly the register's fields: ${ENROLL_TOKEN_FIELDS.join(', ')}`);
109
+ if (statement.purpose !== ENROLL_PURPOSE) flag('/statement/purpose', "an enroll token's purpose is agent-host");
110
+ if (statement.uses !== 1) flag('/statement/uses', 'an enroll token is used once: uses is 1, and the hub binds its nonce on the first use');
111
+ const notBefore = estateTime(statement.notBefore);
112
+ const expiresAt = estateTime(statement.expiresAt);
113
+ const { type: _type, ...rest } = statement;
114
+ const named = extra.length === 0 && missing.length === 0 && statement.purpose === ENROLL_PURPOSE && statement.uses === 1 && Number.isFinite(notBefore) && Number.isFinite(expiresAt);
115
+ if (named && !signingStatement(ENROLL_TOKEN_TYPE, rest)) flag('/statement', 'scope is estate:<id>, genesis a sha256: digest, nonce 32 bytes in base64url, and ceiling null or a digest');
116
+ if (!Number.isFinite(notBefore) || !Number.isFinite(expiresAt)) {
117
+ flag(Number.isFinite(notBefore) ? '/statement/expiresAt' : '/statement/notBefore', 'notBefore and expiresAt are RFC 3339 date-times with an offset, on days that exist');
118
+ } else if (expiresAt <= notBefore || expiresAt - notBefore > MAX_ENROLL_TOKEN_SECONDS * 1000) {
119
+ flag('/statement/expiresAt', 'an enroll token is valid after notBefore and for at most 15 minutes (900 s)');
120
+ }
121
+ return findings;
122
+ }
123
+
124
+ /** FAC-SEM-113 (kind enroll-token); undefined for another kind. */
125
+ export function enrollTokenRules(kind, value) {
126
+ if (kind === 'enroll-token') return tokenFindings(isObject(value) ? value : {});
127
+ return undefined;
128
+ }
129
+ // #endregion ceremonies-kit-node
@@ -285,7 +285,9 @@ export function verifyEstateRecord(record, trust) {
285
285
  // A revision issued a moment ahead of this clock is admitted within the skew every relay time allows (DEC-0066).
286
286
  if (at < issuedAt - CLOCK_SKEW_SECONDS * 1000) return { ok: false, problem: 'not-yet-valid' };
287
287
  if (at > validUntil) return { ok: false, problem: 'expired' };
288
- const codes = [...new Set(recordFindings(record).map((finding) => finding.code))];
288
+ // With a parent, what the revision drops it revokes (FAC-SEM-114, DEC-0084): a removed machine, a replaced key.
289
+ const dropped = isObject(trust.parent) ? droppedFindings(trust.parent, record) : [];
290
+ const codes = [...new Set([...recordFindings(record), ...dropped].map((finding) => finding.code))];
289
291
  if (codes.length > 0) return { ok: false, problem: 'rule-violation', codes };
290
292
  return { ok: true, digest, revision: record.revision, signedBy: credential.credentialId, signCount: assertion.signCount };
291
293
  }
@@ -389,6 +391,27 @@ function revisionChain(value) {
389
391
  return findings;
390
392
  }
391
393
 
394
+ /** Standard base64 with its padding, the one form DEC-0053 records a device key's octets in. */
395
+ const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
396
+ /** DEC-0084: a key reference, a device-key kid, `@sha256:` and 64 lower-case hex digits. */
397
+ export const KEY_REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._:~-]{0,127}#(sig|kem)@sha256:[a-f0-9]{64}$/;
398
+
399
+ /** A device key's fingerprint (DEC-0084): `sha256:` and the hex SHA-256 of its canonical base64 octets, or undefined. */
400
+ export function deviceKeyFingerprint(key) {
401
+ const text = isObject(key) ? key.publicKey : undefined;
402
+ if (!isString(text) || text.length === 0 || !BASE64.test(text)) return undefined;
403
+ const raw = Buffer.from(text, 'base64');
404
+ if (raw.toString('base64') !== text) return undefined;
405
+ return `sha256:${sha256(raw).toString('hex')}`;
406
+ }
407
+
408
+ /** The `device-key` revocation reference of one key (DEC-0084): `<kid>@<fingerprint>`, or undefined. */
409
+ export function deviceKeyRef(key) {
410
+ const fingerprint = deviceKeyFingerprint(key);
411
+ const kid = isObject(key) ? key.kid : undefined;
412
+ return fingerprint !== undefined && isString(kid) ? `${kid}@${fingerprint}` : undefined;
413
+ }
414
+
392
415
  const validDeviceKey = (key) => {
393
416
  const raw = Buffer.from(show(key.publicKey ?? ''), 'base64');
394
417
  return key.alg === 'ed25519' ? raw.length === 32 && !smallOrder(raw) : isValidP256Key(raw);
@@ -408,7 +431,9 @@ function keys(value, flag) {
408
431
  const seen = new Set();
409
432
  deviceKeys.forEach((key, k) => {
410
433
  if (key.kid !== `${show(machine.id)}#${show(key.use)}`) flag(`${at}/deviceKeys/${k}/kid`, 'a device key is listed as <machine id>#sig or <machine id>#kem, by its role');
411
- if (!validDeviceKey(key)) flag(`${at}/deviceKeys/${k}/publicKey`, 'an Ed25519 key is never of small order (DEC-0045) and a P-256 key is a valid point (SEC 1 §3.2.2)');
434
+ // A key with no canonical fingerprint could be read as two keys by two readers, and no key reference names it (DEC-0084).
435
+ if (deviceKeyFingerprint(key) === undefined) flag(`${at}/deviceKeys/${k}/publicKey`, "a device key's octets are written in canonical padded base64, the one form a key reference can name (DEC-0084)");
436
+ else if (!validDeviceKey(key)) flag(`${at}/deviceKeys/${k}/publicKey`, 'an Ed25519 key is never of small order (DEC-0045) and a P-256 key is a valid point (SEC 1 §3.2.2)');
412
437
  if (seen.has(key.publicKey)) flag(`${at}/deviceKeys/${k}/publicKey`, 'one key is never used for the other role');
413
438
  seen.add(key.publicKey);
414
439
  });
@@ -485,7 +510,8 @@ function grantsAndRevocations(value, flag) {
485
510
  const listed = new Map(Object.entries({
486
511
  authority: new Set(list(value.authorities).map((entry) => entry.credentialId)),
487
512
  machine: new Set(machines.map((machine) => machine.id)),
488
- 'device-key': new Set(machines.flatMap((machine) => list(machine.deviceKeys).map((key) => key.kid))),
513
+ // A kid, and each listed key's own reference (DEC-0084): revoking either while listing it is refused.
514
+ 'device-key': new Set(machines.flatMap((machine) => list(machine.deviceKeys).flatMap((key) => [key.kid, deviceKeyRef(key)].filter((ref) => ref !== undefined)))),
489
515
  'hub-key': new Set(machines.flatMap((machine) => (isObject(machine.hubKey) ? [machine.hubKey.kid] : []))),
490
516
  'publisher-key': new Set(list(value.publisherKeys).map((key) => key.kid)),
491
517
  package: new Set(machines.flatMap((machine) => list(machine.packages).map((placement) => placement.digest))),
@@ -496,6 +522,9 @@ function grantsAndRevocations(value, flag) {
496
522
  const ref = revocation.kind === 'grant' ? grantKey(revocation.ref) : revocation.ref;
497
523
  const key = `${show(revocation.kind)} ${show(ref)}`;
498
524
  if (seen.has(key)) flag(`/revocations/${index}`, `the ${show(revocation.kind)} ${show(revocation.ref)} is revoked twice`);
525
+ if (revocation.kind === 'device-key' && typeof revocation.ref === 'string' && revocation.ref.includes('@') && !KEY_REFERENCE.test(revocation.ref)) {
526
+ flag(`/revocations/${index}/ref`, 'a device-key revocation names a kid or a key reference <kid>@sha256:<64 lower-case hex digits> (DEC-0084)');
527
+ }
499
528
  seen.add(key);
500
529
  if (listed.get(show(revocation.kind))?.has(ref)) flag(`/revocations/${index}/ref`, `the record still lists the ${show(revocation.kind)} it revokes: revocation outranks addition`);
501
530
  });
@@ -591,11 +620,45 @@ function installation(value) {
591
620
  return findings;
592
621
  }
593
622
 
623
+ /**
624
+ * FAC-SEM-114 across two revisions (DEC-0084): what a revision drops, it revokes in the same revision. A removed machine
625
+ * as `machine <id>` and its hub key as `hub-key <kid>`; a replaced or dropped device key of a kept machine as `device-key`
626
+ * by its reference or its kid; a hub key no longer certified as it was as `hub-key <old kid>`.
627
+ */
628
+ function droppedFindings(parent, next) {
629
+ const findings = [];
630
+ const flag = flagger(findings, 'FAC-SEM-114');
631
+ const revoked = (kind, ...refs) => list(next.revocations).some((entry) => entry.kind === kind && refs.some((ref) => ref !== undefined && entry.ref === ref));
632
+ const nextMachines = new Map();
633
+ for (const machine of list(next.machines)) if (!nextMachines.has(machine.id)) nextMachines.set(machine.id, machine);
634
+ indexed(parent.machines).forEach(([machine, m]) => {
635
+ const at = `/parent/machines/${m}`;
636
+ const hub = isObject(machine.hubKey) ? machine.hubKey : undefined;
637
+ const kept = nextMachines.get(machine.id);
638
+ if (!kept) {
639
+ if (!revoked('machine', machine.id)) flag(`${at}/id`, `machine ${show(machine.id)} is removed without its revocation: a revision that drops a machine lists machine ${show(machine.id)} in revocations`);
640
+ if (hub && !revoked('hub-key', hub.kid)) flag(`${at}/hubKey/kid`, `the hub key ${show(hub.kid)} of a removed machine is revoked in the same revision`);
641
+ return;
642
+ }
643
+ const listedRefs = new Set(list(kept.deviceKeys).map(deviceKeyRef).filter((ref) => ref !== undefined));
644
+ indexed(machine.deviceKeys).forEach(([key, k]) => {
645
+ const ref = deviceKeyRef(key);
646
+ if (ref === undefined || listedRefs.has(ref)) return;
647
+ if (!revoked('device-key', ref, key.kid)) flag(`${at}/deviceKeys/${k}`, `the device key ${show(key.kid)} is replaced or dropped without its revocation: the same revision lists device-key ${ref}`);
648
+ });
649
+ const nextHub = isObject(kept.hubKey) ? kept.hubKey : undefined;
650
+ if (hub && !(nextHub && nextHub.kid === hub.kid && nextHub.x === hub.x) && !revoked('hub-key', hub.kid)) {
651
+ flag(`${at}/hubKey/kid`, `the hub key ${show(hub.kid)} is no longer certified as it was: the same revision revokes hub-key ${show(hub.kid)}, and a re-certified hub key takes a new kid`);
652
+ }
653
+ });
654
+ return findings;
655
+ }
656
+
594
657
  /** FAC-SEM-072…076 for an `estate-record`, an `estate-record-revision` `{parent, next}` or an `estate-placement`. */
595
658
  export function estateRecordRules(kind, value) {
596
659
  const doc = isObject(value) ? value : {};
597
660
  if (kind === 'estate-record') return recordFindings(doc);
598
- if (kind === 'estate-record-revision') return revisionChain(doc);
661
+ if (kind === 'estate-record-revision') return [...revisionChain(doc), ...droppedFindings(isObject(doc.parent) ? doc.parent : {}, isObject(doc.next) ? doc.next : {})];
599
662
  if (kind === 'estate-placement') return installation(doc);
600
663
  return undefined;
601
664
  }