@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
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env python3
2
+ """The estate ceremonies' headless join (contract DEC-0084), the Python kit; the Node twin is fabric-ceremonies.mjs.
3
+ Standard library only.
4
+
5
+ An ``enroll-token/1`` lets a machine with no screen join an estate: Fabric issues it under the person's signing passkey
6
+ for at most 15 minutes and one use, the machine reads it from stdin or a 0600 file (never argv, a URL or a log) and
7
+ presents it with its ``#sig`` key, and the hub binds its nonce to that key on the first use. The token gives the machine
8
+ no trust; only a passkey-signed revision that lists it does. Its ``genesis`` authenticates the estate to the machine.
9
+
10
+ - ``verify_enroll_token(token, record=, genesis=, at=)``: the reference verifier, the hub's and the node's.
11
+ - ``enroll_join_input(token)``: the ``enroll-join/1`` statement the joining machine signs with its ``#sig`` key.
12
+ - ``enroll_token_rules("enroll-token", token)``: FAC-SEM-113.
13
+
14
+ Every function answers and never raises on malformed input. Normative source: the contract's src/enroll-token.ts,
15
+ src/signing-challenges.ts and docs/specification/ceremonies.md.
16
+ """
17
+ # #region ceremonies-kit, docs: plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md#the-estate-ceremonies
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import re
23
+ import sys
24
+ from pathlib import Path
25
+ from typing import Any, Dict, List, Optional
26
+
27
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
28
+ import fabric_delegation as _delegation # noqa: E402 - beside this file: RFC 8785
29
+ import fabric_estate as _estate # noqa: E402 - beside this file: passkey assertions, times, show
30
+
31
+ ENROLL_TOKEN_SCHEMA = "enroll-token/1"
32
+ ENROLL_TOKEN_TYPE = "enroll-token/1"
33
+ ENROLL_JOIN_TYPE = "enroll-join/1"
34
+ ENROLL_PURPOSE = "agent-host"
35
+ MAX_ENROLL_TOKEN_SECONDS = 900
36
+ #: The register's fields of enroll-token/1 beside ``type`` (DEC-0084), one list for good.
37
+ ENROLL_TOKEN_FIELDS = ("scope", "genesis", "purpose", "ceiling", "notBefore", "expiresAt", "nonce", "uses")
38
+ _SHA256 = re.compile(r"sha256:[a-f0-9]{64}\Z")
39
+ _ESTATE_SCOPE = re.compile(r"estate:[A-Za-z0-9][A-Za-z0-9._:~-]{0,127}\Z")
40
+ #: 32 random bytes as unpadded base64url.
41
+ _NONCE32 = re.compile(r"[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]\Z")
42
+ _ASSERTION_MEMBERS = "alg,authenticatorData,clientDataJSON,credentialId,format,sig"
43
+
44
+
45
+ def _matches(pattern: Any, value: Any) -> bool:
46
+ return isinstance(value, str) and pattern.match(value) is not None
47
+
48
+
49
+ def _is_one(value: Any) -> bool:
50
+ """JavaScript's ``value === 1`` on parsed JSON: true is not 1."""
51
+ return not isinstance(value, bool) and isinstance(value, (int, float)) and value == 1
52
+
53
+
54
+ def _finite(value: float) -> bool:
55
+ return value == value and value not in (float("inf"), float("-inf"))
56
+
57
+
58
+ def _register_ok(kind: str, fields: Dict[str, Any]) -> bool:
59
+ if kind == ENROLL_TOKEN_TYPE:
60
+ return (_matches(_ESTATE_SCOPE, fields["scope"]) and _matches(_SHA256, fields["genesis"]) and fields["purpose"] == ENROLL_PURPOSE
61
+ and (fields["ceiling"] is None or _matches(_SHA256, fields["ceiling"])) and _finite(_estate.estate_time(fields["notBefore"]))
62
+ and _finite(_estate.estate_time(fields["expiresAt"])) and _matches(_NONCE32, fields["nonce"]) and _is_one(fields["uses"]))
63
+ return _matches(_SHA256, fields["digest"]) and _matches(_NONCE32, fields["nonce"])
64
+
65
+
66
+ def signing_statement(kind: str, fields: Any) -> Optional[bytes]:
67
+ """The bytes of a registered statement (DEC-0063): the UTF-8 RFC 8785 form of ``{type, ...fields}``, for the two types
68
+ this kit uses (``enroll-token/1``, ``enroll-join/1``); None when the fields are not exactly the type's or a value is
69
+ outside what the register admits."""
70
+ names = {ENROLL_TOKEN_TYPE: ENROLL_TOKEN_FIELDS, ENROLL_JOIN_TYPE: ("digest", "nonce")}.get(kind)
71
+ if names is None or not isinstance(fields, dict) or len(fields) != len(names) or any(name not in fields for name in names):
72
+ return None
73
+ if not _register_ok(kind, fields):
74
+ return None
75
+ try:
76
+ return _delegation.jcs(dict(fields, type=kind)).encode("utf-8")
77
+ except (TypeError, ValueError):
78
+ return None
79
+
80
+
81
+ def _statement_fields(token: Any) -> Optional[Dict[str, Any]]:
82
+ if not isinstance(token, dict) or token.get("schema") != ENROLL_TOKEN_SCHEMA or not isinstance(token.get("statement"), dict):
83
+ return None
84
+ if token["statement"].get("type") != ENROLL_TOKEN_TYPE:
85
+ return None
86
+ # The token holds exactly the schema's members: nothing unsigned rides beside the statement (DEC-0084).
87
+ if sorted(token) != ["schema", "signature", "statement"]:
88
+ return None
89
+ fields = {name: value for name, value in token["statement"].items() if name != "type"}
90
+ return fields if signing_statement(ENROLL_TOKEN_TYPE, fields) is not None else None
91
+
92
+
93
+ def verify_enroll_token(token: Any, *, record: Any, genesis: Any, at: Any) -> Dict[str, Any]:
94
+ """The reference verifier of an enroll-token/1, for the hub that admits a join and the node that pins the estate.
95
+ ``record`` is the current estate revision the verifier applied, ``genesis`` the digest of its revision 1, ``at`` the
96
+ clock (an aware datetime or epoch seconds). The first failure answers: no-clock, malformed, wrong-estate, the passkey
97
+ (no-trusted-authority, revoked-authority, duplicate-authority, unknown-authority), the assertion, then the window
98
+ (too-long-lived, not-yet-valid, expired). Success: ``{"ok": True, "nonce", "expiresAt", "signedBy"}``."""
99
+ now = _estate._clock(at)
100
+ if not _finite(now):
101
+ return {"ok": False, "problem": "no-clock"}
102
+ fields = _statement_fields(token)
103
+ if fields is None or not isinstance(record, dict) or not isinstance(record.get("estate"), str):
104
+ return {"ok": False, "problem": "malformed"}
105
+ signature = token.get("signature")
106
+ if (not isinstance(signature, dict) or ",".join(sorted(signature)) != _ASSERTION_MEMBERS
107
+ or signature.get("format") != "webauthn-assertion" or not isinstance(signature.get("credentialId"), str)):
108
+ return {"ok": False, "problem": "malformed"}
109
+ if fields["scope"] != "estate:%s" % record["estate"] or not isinstance(genesis, str) or fields["genesis"] != genesis:
110
+ return {"ok": False, "problem": "wrong-estate"}
111
+ authorities = _estate._list(record.get("authorities"))
112
+ if not authorities:
113
+ return {"ok": False, "problem": "no-trusted-authority"}
114
+ credential_id = signature["credentialId"]
115
+ if any(entry.get("kind") == "authority" and _estate._same(entry.get("ref", _estate._MISSING), credential_id) for entry in _estate._list(record.get("revocations"))):
116
+ return {"ok": False, "problem": "revoked-authority"}
117
+ matches = [entry for entry in authorities if _estate._same(entry.get("credentialId", _estate._MISSING), credential_id)]
118
+ if len(matches) > 1:
119
+ return {"ok": False, "problem": "duplicate-authority"}
120
+ if not matches:
121
+ return {"ok": False, "problem": "unknown-authority"}
122
+ authority = matches[0]
123
+ origins = authority.get("origins")
124
+ rp_id = _estate._show(authority.get("rpId", _estate._MISSING))
125
+ if not _estate.is_signing_rp_id(rp_id):
126
+ return {"ok": False, "problem": "wrong-rp"}
127
+ credential = {"credentialId": credential_id, "alg": _estate._show(authority.get("alg", _estate._MISSING)),
128
+ "publicKey": authority["publicKey"] if isinstance(authority.get("publicKey"), dict) else {}, "rpId": rp_id,
129
+ "origins": [o for o in origins if isinstance(o, str) and _estate.origin_within(o, rp_id)] if isinstance(origins, list) else []}
130
+ challenge = hashlib.sha256(signing_statement(ENROLL_TOKEN_TYPE, fields)).digest()
131
+ assertion = _estate.verify_assertion(signature, credential, challenge)
132
+ if not assertion["ok"]:
133
+ return {"ok": False, "problem": assertion["problem"]}
134
+ not_before = _estate.estate_time(fields["notBefore"])
135
+ expires_at = _estate.estate_time(fields["expiresAt"])
136
+ if expires_at <= not_before or expires_at - not_before > MAX_ENROLL_TOKEN_SECONDS * 1000:
137
+ return {"ok": False, "problem": "too-long-lived"}
138
+ if now < not_before - _estate.CLOCK_SKEW_SECONDS * 1000:
139
+ return {"ok": False, "problem": "not-yet-valid"}
140
+ if now > expires_at:
141
+ return {"ok": False, "problem": "expired"}
142
+ return {"ok": True, "nonce": fields["nonce"], "expiresAt": fields["expiresAt"], "signedBy": credential_id}
143
+
144
+
145
+ def enroll_join_input(token: Any) -> Optional[str]:
146
+ """What the joining machine signs with the #sig key it presents (DEC-0084): the enroll-join/1 statement
147
+ ``{type, digest, nonce}``, ``digest`` the SHA-256 of the token's statement bytes; None without a register statement."""
148
+ fields = _statement_fields(token)
149
+ statement = signing_statement(ENROLL_TOKEN_TYPE, fields) if fields is not None else None
150
+ if statement is None:
151
+ return None
152
+ joined = signing_statement(ENROLL_JOIN_TYPE, {"digest": "sha256:" + hashlib.sha256(statement).hexdigest(), "nonce": fields["nonce"]})
153
+ return joined.decode("utf-8") if joined is not None else None
154
+
155
+
156
+ def _token_findings(value: Dict[str, Any]) -> List[Dict[str, str]]:
157
+ findings: List[Dict[str, str]] = []
158
+
159
+ def flag(at: str, message: str) -> None:
160
+ findings.append({"code": "FAC-SEM-113", "instancePath": at, "message": message})
161
+
162
+ statement = value.get("statement") if isinstance(value.get("statement"), dict) else None
163
+ if statement is None:
164
+ flag("/statement", "an enroll token carries its signed statement")
165
+ return findings
166
+ if statement.get("type") != ENROLL_TOKEN_TYPE:
167
+ flag("/statement/type", "the statement's type is enroll-token/1, the register's (DEC-0063)")
168
+ extra = [name for name in statement if name != "type" and name not in ENROLL_TOKEN_FIELDS]
169
+ missing = [name for name in ENROLL_TOKEN_FIELDS if name not in statement]
170
+ if extra or missing:
171
+ flag("/statement", "the statement has exactly the register's fields: %s" % ", ".join(ENROLL_TOKEN_FIELDS))
172
+ if statement.get("purpose") != ENROLL_PURPOSE:
173
+ flag("/statement/purpose", "an enroll token's purpose is agent-host")
174
+ if not _is_one(statement.get("uses")):
175
+ flag("/statement/uses", "an enroll token is used once: uses is 1, and the hub binds its nonce on the first use")
176
+ not_before = _estate.estate_time(statement.get("notBefore"))
177
+ expires_at = _estate.estate_time(statement.get("expiresAt"))
178
+ rest = {name: v for name, v in statement.items() if name != "type"}
179
+ named = not extra and not missing and statement.get("purpose") == ENROLL_PURPOSE and _is_one(statement.get("uses")) and _finite(not_before) and _finite(expires_at)
180
+ if named and signing_statement(ENROLL_TOKEN_TYPE, rest) is None:
181
+ flag("/statement", "scope is estate:<id>, genesis a sha256: digest, nonce 32 bytes in base64url, and ceiling null or a digest")
182
+ if not _finite(not_before) or not _finite(expires_at):
183
+ flag("/statement/expiresAt" if _finite(not_before) else "/statement/notBefore", "notBefore and expiresAt are RFC 3339 date-times with an offset, on days that exist")
184
+ elif expires_at <= not_before or expires_at - not_before > MAX_ENROLL_TOKEN_SECONDS * 1000:
185
+ flag("/statement/expiresAt", "an enroll token is valid after notBefore and for at most 15 minutes (900 s)")
186
+ return findings
187
+
188
+
189
+ def enroll_token_rules(kind: str, value: Any) -> Optional[List[Dict[str, str]]]:
190
+ """FAC-SEM-113 (kind ``enroll-token``); None for another kind."""
191
+ if kind == "enroll-token":
192
+ return _token_findings(value if isinstance(value, dict) else {})
193
+ return None
194
+ # #endregion ceremonies-kit
@@ -715,7 +715,9 @@ def verify_estate_record(record: Any, *, at: Any, parent: Any = _MISSING, author
715
715
  if now > valid_until:
716
716
  return {"ok": False, "problem": "expired"}
717
717
  codes: List[str] = []
718
- for finding in _record_findings(record):
718
+ # With a parent, what the revision drops it revokes (FAC-SEM-114, DEC-0084): a removed machine, a replaced key.
719
+ dropped = _dropped_findings(parent, record) if isinstance(parent, dict) else []
720
+ for finding in _record_findings(record) + dropped:
719
721
  if finding["code"] not in codes:
720
722
  codes.append(finding["code"])
721
723
  if codes:
@@ -851,6 +853,32 @@ def _revision_chain(value: Dict[str, Any]) -> List[Dict[str, str]]:
851
853
  return findings
852
854
 
853
855
 
856
+ #: Standard base64 with its padding, the one form DEC-0053 records a device key's octets in.
857
+ _BASE64 = re.compile(r"(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?\Z")
858
+ #: DEC-0084: a key reference, a device-key kid, ``@sha256:`` and 64 lower-case hex digits.
859
+ KEY_REFERENCE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:~-]{0,127}#(sig|kem)@sha256:[a-f0-9]{64}\Z")
860
+
861
+
862
+ def device_key_fingerprint(key: Any) -> Optional[str]:
863
+ """A device key's fingerprint (DEC-0084): ``sha256:`` and the hex SHA-256 of its octets, ``publicKey`` read as
864
+ canonical padded base64; None for any other form, so two readers never disagree on which key it names."""
865
+ text = key.get("publicKey") if isinstance(key, dict) else None
866
+ if not isinstance(text, str) or not text or not _BASE64.match(text):
867
+ return None
868
+ raw = base64.b64decode(text)
869
+ if base64.b64encode(raw).decode("ascii") != text:
870
+ return None
871
+ return "sha256:" + hashlib.sha256(raw).hexdigest()
872
+
873
+
874
+ def device_key_ref(key: Any) -> Optional[str]:
875
+ """The ``device-key`` revocation reference of one key (DEC-0084): ``<kid>@<fingerprint>``; None without a string kid
876
+ or a fingerprint. A replacement revokes the old key by it and leaves the next key under the same kid usable."""
877
+ fingerprint = device_key_fingerprint(key)
878
+ kid = key.get("kid") if isinstance(key, dict) else None
879
+ return "%s@%s" % (kid, fingerprint) if fingerprint is not None and isinstance(kid, str) else None
880
+
881
+
854
882
  def _valid_device_key(key: Dict[str, Any]) -> bool:
855
883
  public = _get(key, "publicKey")
856
884
  raw = _node_base64("" if public is _MISSING or public is None else _show(public))
@@ -877,7 +905,10 @@ def _keys(value: Dict[str, Any], flag) -> None:
877
905
  for key, k in _indexed(_get(machine, "deviceKeys")):
878
906
  if not _same(_get(key, "kid"), "%s#%s" % (_show(_get(machine, "id")), _show(_get(key, "use")))):
879
907
  flag("%s/deviceKeys/%d/kid" % (at, k), "a device key is listed as <machine id>#sig or <machine id>#kem, by its role")
880
- if not _valid_device_key(key):
908
+ # A key with no canonical fingerprint could be read as two keys by two readers, and no key reference names it (DEC-0084).
909
+ if device_key_fingerprint(key) is None:
910
+ flag("%s/deviceKeys/%d/publicKey" % (at, k), "a device key's octets are written in canonical padded base64, the one form a key reference can name (DEC-0084)")
911
+ elif not _valid_device_key(key):
881
912
  flag("%s/deviceKeys/%d/publicKey" % (at, k), "an Ed25519 key is never of small order (DEC-0045) and a P-256 key is a valid point (SEC 1 §3.2.2)")
882
913
  if _contains(seen, _get(key, "publicKey")):
883
914
  flag("%s/deviceKeys/%d/publicKey" % (at, k), "one key is never used for the other role")
@@ -966,7 +997,9 @@ def _grants_and_revocations(value: Dict[str, Any], flag) -> None:
966
997
  listed = {
967
998
  "authority": [_get(e, "credentialId") for e in _list(_get(value, "authorities"))],
968
999
  "machine": [_get(m, "id") for m in machines],
969
- "device-key": [_get(k, "kid") for m in machines for k in _list(_get(m, "deviceKeys"))],
1000
+ # A kid, and each listed key's own reference (DEC-0084): revoking either while listing it is refused.
1001
+ "device-key": [ref for m in machines for k in _list(_get(m, "deviceKeys"))
1002
+ for ref in ([_get(k, "kid")] if _get(k, "kid") is not _MISSING else []) + ([device_key_ref(k)] if device_key_ref(k) is not None else [])],
970
1003
  "hub-key": [_get(m["hubKey"], "kid") for m in machines if isinstance(_get(m, "hubKey"), dict)],
971
1004
  "publisher-key": [_get(k, "kid") for k in _list(_get(value, "publisherKeys"))],
972
1005
  "package": [_get(p, "digest") for m in machines for p in _list(_get(m, "packages"))],
@@ -979,6 +1012,9 @@ def _grants_and_revocations(value: Dict[str, Any], flag) -> None:
979
1012
  key = "%s %s" % (kind, _show(ref))
980
1013
  if key in seen:
981
1014
  flag("/revocations/%d" % index, "the %s %s is revoked twice" % (kind, _show(_get(revocation, "ref"))))
1015
+ raw_ref = _get(revocation, "ref")
1016
+ if _get(revocation, "kind") == "device-key" and isinstance(raw_ref, str) and "@" in raw_ref and not KEY_REFERENCE.match(raw_ref):
1017
+ flag("/revocations/%d/ref" % index, "a device-key revocation names a kid or a key reference <kid>@sha256:<64 lower-case hex digits> (DEC-0084)")
982
1018
  seen.append(key)
983
1019
  if _contains(listed.get(kind, []), ref):
984
1020
  flag("/revocations/%d/ref" % index, "the record still lists the %s it revokes: revocation outranks addition" % kind)
@@ -1029,6 +1065,46 @@ def _placement_grants(value: Dict[str, Any], flag) -> None:
1029
1065
  flag("/machines/%d/packages/%d%s" % (m, p, suffix), message)
1030
1066
 
1031
1067
 
1068
+ def _dropped_findings(parent: Dict[str, Any], nxt: Dict[str, Any]) -> List[Dict[str, str]]:
1069
+ """FAC-SEM-114 across two revisions (DEC-0084): what a revision drops, it revokes in the same revision. A removed
1070
+ machine as ``machine <id>`` and its hub key as ``hub-key <kid>``; a replaced or dropped device key of a kept machine
1071
+ as ``device-key`` by its reference or its kid; a hub key no longer certified as it was as ``hub-key <old kid>``."""
1072
+ findings: List[Dict[str, str]] = []
1073
+ flag = _finding(findings, "FAC-SEM-114")
1074
+ revocations = _list(_get(nxt, "revocations"))
1075
+
1076
+ def revoked(kind: str, *refs: Any) -> bool:
1077
+ return any(_get(entry, "kind") == kind and any(ref is not _MISSING and _same(_get(entry, "ref"), ref) for ref in refs)
1078
+ for entry in revocations)
1079
+
1080
+ kept_machines: List[Tuple[Any, Dict[str, Any]]] = []
1081
+ for machine in _list(_get(nxt, "machines")):
1082
+ if not any(_same(ident, _get(machine, "id")) for ident, _ in kept_machines):
1083
+ kept_machines.append((_get(machine, "id"), machine))
1084
+ for machine, m in _indexed(_get(parent, "machines")):
1085
+ at = "/parent/machines/%d" % m
1086
+ hub = _get(machine, "hubKey") if isinstance(_get(machine, "hubKey"), dict) else None
1087
+ kept = next((found for ident, found in kept_machines if _same(ident, _get(machine, "id"))), None)
1088
+ if kept is None:
1089
+ if not revoked("machine", _get(machine, "id")):
1090
+ flag("%s/id" % at, "machine %s is removed without its revocation: a revision that drops a machine lists machine %s in revocations" % (_show(_get(machine, "id")), _show(_get(machine, "id"))))
1091
+ if hub is not None and not revoked("hub-key", _get(hub, "kid")):
1092
+ flag("%s/hubKey/kid" % at, "the hub key %s of a removed machine is revoked in the same revision" % _show(_get(hub, "kid")))
1093
+ continue
1094
+ listed = {ref for ref in (device_key_ref(key) for key in _list(_get(kept, "deviceKeys"))) if ref is not None}
1095
+ for key, k in _indexed(_get(machine, "deviceKeys")):
1096
+ ref = device_key_ref(key)
1097
+ if ref is None or ref in listed:
1098
+ continue
1099
+ if not revoked("device-key", ref, _get(key, "kid")):
1100
+ flag("%s/deviceKeys/%d" % (at, k), "the device key %s is replaced or dropped without its revocation: the same revision lists device-key %s" % (_show(_get(key, "kid")), ref))
1101
+ next_hub = _get(kept, "hubKey") if isinstance(_get(kept, "hubKey"), dict) else None
1102
+ same_hub = next_hub is not None and _same(_get(next_hub, "kid"), _get(hub, "kid") if hub else _MISSING) and _same(_get(next_hub, "x"), _get(hub, "x") if hub else _MISSING)
1103
+ if hub is not None and not same_hub and not revoked("hub-key", _get(hub, "kid")):
1104
+ flag("%s/hubKey/kid" % at, "the hub key %s is no longer certified as it was: the same revision revokes hub-key %s, and a re-certified hub key takes a new kid" % (_show(_get(hub, "kid")), _show(_get(hub, "kid"))))
1105
+ return findings
1106
+
1107
+
1032
1108
  def _record_findings(value: Dict[str, Any]) -> List[Dict[str, str]]:
1033
1109
  findings: List[Dict[str, str]] = []
1034
1110
  _revision_whole(value, _finding(findings, "FAC-SEM-072"))
@@ -1095,7 +1171,9 @@ def estate_record_rules(kind: str, value: Any) -> Optional[List[Dict[str, str]]]
1095
1171
  if kind == "estate-record":
1096
1172
  return _record_findings(value)
1097
1173
  if kind == "estate-record-revision":
1098
- return _revision_chain(value)
1174
+ parent = value["parent"] if isinstance(_get(value, "parent"), dict) else {}
1175
+ nxt = value["next"] if isinstance(_get(value, "next"), dict) else {}
1176
+ return _revision_chain(value) + _dropped_findings(parent, nxt)
1099
1177
  if kind == "estate-placement":
1100
1178
  return _installation(value)
1101
1179
  return None