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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +45 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +37 -1
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +13 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +129 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +67 -4
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +252 -5
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +5 -4
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +4 -3
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +109 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +194 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +82 -4
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +356 -9
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +10 -4
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -4
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +173 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/share-registration.schema.json +218 -0
  25. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  26. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +13 -4
  27. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  28. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +3 -0
  29. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +20 -5
package/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.36.0 - 2026-10-19
4
+
5
+ Repinned to Fabric Agent Contract `07042181bfae2a481aa0da1a5651f24c76669c48` (DEC-0084 to DEC-0088); `d8146cf` stays
6
+ supported, so bundles issued under it stay valid. DEC-0085 and DEC-0088 (entitlements) are the platform's and the
7
+ organization server's, not the kits'.
8
+
9
+ ### Added
10
+
11
+ - **Jobs under limits and uncertain effects, in both interop kits** (FAA-56, contract DEC-0087, FAC-SEM-119/120): a job a
12
+ limit paused stays `working` with `quota-exhausted until <instant>` (`jobs.pause`); a job a limit stopped ends
13
+ `quota-exhausted` with `data: {retryable, resetsAt?}` (`jobs.quota_exhausted`); an effect nobody saw ends
14
+ `outcome-unknown` (`jobs.outcome_unknown`). Idempotency keys are kept per authenticated caller and a repeated key is
15
+ answered by its job (`jobs.repeat`): a limited job runs again once the limit no longer holds, an `outcome-unknown` one
16
+ never. Usage nobody measured is `null` only toward a caller that names `fabric-interop/0.2` (`usage_toward`). As a
17
+ caller: `request_meta`, `job_caller_action`, `intent_idempotency_key`. Held to the contract's `job-limits.json`.
18
+ - **Share grants into an organization** (contract DEC-0086): new `fabric_share.py` / `fabric-share.mjs` with FAC-SEM-117
19
+ (a share grant id, a millisecond window, one agentRef kept against the parent revision), FAC-SEM-118 (a
20
+ `share-registration/1` document) and FAC-SEM-045's `grant.usage` context clause; `share-registration.schema.json`
21
+ vendored self-contained.
22
+ - **The estate ceremonies** (contract DEC-0084): in both estate kits FAC-SEM-114 (what a revision drops it revokes in the
23
+ same revision), FAC-SEM-073 (a device key in canonical base64), FAC-SEM-076 (a key reference
24
+ `<kid>@sha256:<hex>`), `device_key_fingerprint`, `device_key_ref`, and `verify_estate_record(..., parent=)` runs
25
+ FAC-SEM-114. New `fabric_ceremonies.py` / `fabric-ceremonies.mjs`: `verify_enroll_token`, `enroll_join_input`,
26
+ FAC-SEM-113. Pairing and the hub's single use of a token follow in FAA-57.
27
+ - **A partner's setup for a company** (FAA-55, the partner channel's PA-07): documented and tested as an org skill set
28
+ whose `requires.packages` names the agents, every package signed with the partner's key. No new format.
29
+
30
+ ### Changed
31
+
32
+ - **FAC-SEM-034** in both profile kits compares the probes before it builds a list and names at most 64 candidates, so a
33
+ huge index allocates nothing (the contract's fix of this repository's report).
34
+ - **The protocol register** in both service kits matches the contract's: `enroll-token`, `entitlement`,
35
+ `entitlement-key-set`, `pairing-message`, `share-registration`, `share-registration-receipt`, and `fabric-interop/0.2`.
36
+
37
+ ## 0.35.4 - 2026-10-19
38
+
39
+ ### Changed
40
+
41
+ - **The session hooks rejoin a session at most once a minute** (FAA-53). Every `fabric-mcp --rejoin-only` spends the
42
+ agent's Fabric request budget, which every session on the machine shares; with about forty Claude Code sessions,
43
+ agents waited minutes on "Fabric is rate limited". The worker marks a successful rejoin in the session's folder; a
44
+ turn within 60 s of it is spooled and delivered by the next worker, and the end of a session always starts one.
45
+ Fabric fixed its side in CO-334 (per-session charging, jitter after `Retry-After`) and advised this skip.
46
+ - **`creating-fabric-agents` → resilience reference follows contract DEC-0087** (FAA-56, guidance part): the codes
47
+ are `quota-exhausted` and `outcome-unknown`; a job a limit only paused stays `working` with the pause grammar in its
48
+ `statusMessage`; a job its provider will not resume ends `failed` with `data: {retryable, resetsAt?}`; a caller acts
49
+ as `jobCallerAction` does. The kits' rules and vectors come with the repin (FAA-54).
50
+
3
51
  ## 0.35.3 - 2026-10-19
4
52
 
5
53
  ### Added
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.3",
3
+ "version": "0.36.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.3",
6
+ "version": "0.36.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.3"
8
+ version: "0.36.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.3"
21
+ version: "0.36.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
  }