sengol-verify 2.1.0__py3-none-any.whl

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.
@@ -0,0 +1,7 @@
1
+ """Standalone offline verifier for sengol evidence bundles.
2
+
3
+ Pure-Python, no dependency on the ``sengol`` package or any network access —
4
+ see the import-guard test for the enforced boundary.
5
+ """
6
+
7
+ __version__ = "2.1.0"
@@ -0,0 +1,144 @@
1
+ """Loads a sengol evidence bundle from a JSON file, a directory, or a zip.
2
+
3
+ ``sengol audit export`` writes a single self-contained JSON file:
4
+ ``{"records": [...], "countersignatures": [...], "anchor_receipts": [...],
5
+ "public_keys": {...}}`` (optionally with "pending_countersignatures" and,
6
+ for a JudgeEvidencePack, a "judge_evidence_pack" envelope key). This module
7
+ accepts that file directly, a zip containing exactly one such JSON file, or
8
+ a directory containing one.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import zipfile
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from sengol_verify.canonical import Record, UnknownRecordType, family_for
19
+
20
+ __all__ = [
21
+ "Countersignature",
22
+ "AnchorReceipt",
23
+ "load_bundle",
24
+ "reconstruct_records",
25
+ "reconstruct_countersigs",
26
+ "reconstruct_anchors",
27
+ ]
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Countersignature:
32
+ countersig_id: str
33
+ record_id: str
34
+ tenant_id: str
35
+ agent_id: str
36
+ payload_hash: str
37
+ algorithm: str
38
+ key_id: str
39
+ signature: str
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class AnchorReceipt:
44
+ anchor_id: str
45
+ tenant_id: str
46
+ agent_id: str
47
+ merkle_root: str
48
+ leaf_count: int
49
+ seq_low: int
50
+ seq_high: int
51
+ leaf_hashes: list
52
+ tsa_receipt: str | None = None
53
+ tsa_url: str | None = None
54
+ rekor_log_id: str | None = None
55
+
56
+
57
+ class BundleFormatError(ValueError):
58
+ """The given path does not contain a single, readable bundle JSON."""
59
+
60
+
61
+ def _read_json_bundle(path: Path) -> dict:
62
+ try:
63
+ return json.loads(path.read_text())
64
+ except Exception as exc:
65
+ raise BundleFormatError(f"{path}: not valid JSON ({exc})") from exc
66
+
67
+
68
+ def load_bundle(path: Path) -> dict:
69
+ """Return the bundle dict for a .json file, a directory, or a zip."""
70
+ if path.is_dir():
71
+ candidates = sorted(path.glob("*.json"))
72
+ if len(candidates) != 1:
73
+ raise BundleFormatError(
74
+ f"{path}: expected exactly one .json file, found {len(candidates)}"
75
+ )
76
+ return _read_json_bundle(candidates[0])
77
+
78
+ if zipfile.is_zipfile(path):
79
+ with zipfile.ZipFile(path) as zf:
80
+ names = [n for n in zf.namelist() if n.endswith(".json")]
81
+ if len(names) != 1:
82
+ raise BundleFormatError(
83
+ f"{path}: expected exactly one .json member, found {len(names)}"
84
+ )
85
+ with zf.open(names[0]) as fh:
86
+ try:
87
+ return json.loads(fh.read())
88
+ except Exception as exc:
89
+ raise BundleFormatError(f"{path}: not valid JSON ({exc})") from exc
90
+
91
+ return _read_json_bundle(path)
92
+
93
+
94
+ def _record(raw: dict) -> Record:
95
+ try:
96
+ return Record(raw, family_for(raw))
97
+ except UnknownRecordType:
98
+ return Record(raw, None)
99
+
100
+
101
+ def reconstruct_records(bundle: dict) -> list:
102
+ """One ``Record`` per bundle record. A record whose ``record_type`` is not
103
+ registered gets ``family=None``: it is kept, so verification reports it,
104
+ but it has no canonical payload."""
105
+ return [_record(raw) for raw in bundle.get("records", [])]
106
+
107
+
108
+ def reconstruct_countersigs(bundle: dict) -> list:
109
+ out = []
110
+ for raw in bundle.get("countersignatures", []):
111
+ out.append(
112
+ Countersignature(
113
+ countersig_id=str(raw.get("countersig_id", "")),
114
+ record_id=str(raw.get("record_id", "")),
115
+ tenant_id=raw.get("tenant_id", ""),
116
+ agent_id=raw.get("agent_id", ""),
117
+ payload_hash=raw.get("payload_hash", ""),
118
+ algorithm=raw.get("algorithm", "ed25519"),
119
+ key_id=raw.get("key_id", ""),
120
+ signature=raw.get("signature", ""),
121
+ )
122
+ )
123
+ return out
124
+
125
+
126
+ def reconstruct_anchors(bundle: dict) -> list:
127
+ out = []
128
+ for raw in bundle.get("anchor_receipts", []):
129
+ out.append(
130
+ AnchorReceipt(
131
+ anchor_id=str(raw.get("anchor_id", "")),
132
+ tenant_id=raw.get("tenant_id", ""),
133
+ agent_id=raw.get("agent_id", ""),
134
+ merkle_root=raw.get("merkle_root", ""),
135
+ leaf_count=raw.get("leaf_count", 0),
136
+ seq_low=raw.get("seq_low", 0),
137
+ seq_high=raw.get("seq_high", 0),
138
+ leaf_hashes=list(raw.get("leaf_hashes", [])),
139
+ tsa_receipt=raw.get("tsa_receipt"),
140
+ tsa_url=raw.get("tsa_url"),
141
+ rekor_log_id=raw.get("rekor_log_id"),
142
+ )
143
+ )
144
+ return out
@@ -0,0 +1,390 @@
1
+ """Canonical signed-payload rule, vendored from sengol's
2
+ ``sengol/core/payload_registry.py`` (ADR-0019).
3
+
4
+ One rule signs every record: RFC 8785 (JSON Canonicalization Scheme) over
5
+ ``{"record": <fields>, "type": "<family>"}``, where ``<fields>`` is the
6
+ record's JSON-mode values minus the signature fields and the family's
7
+ ``UNSIGNED_FIELDS``. The per-family/per-version tables below (``_VERSIONS``,
8
+ ``_UNSIGNED_FIELDS``, ``_NESTED_UNSIGNED_FIELDS``, ``_UNORDERED_FIELDS``)
9
+ mirror ``sengol/core/types.py`` and ``payload_registry.py`` —
10
+ changing one here would make this tool disagree with sengol about what a
11
+ genuine signature covers.
12
+
13
+ This module has no import on the ``sengol`` package. ``Record`` duck-types
14
+ attribute access over a plain dict reconstructed from stored/exported JSON,
15
+ which is what ``canonical_payload`` actually reads (via ``record._raw``).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from datetime import datetime
21
+ from typing import Any
22
+
23
+ import rfc8785
24
+
25
+ __all__ = [
26
+ "Record",
27
+ "canonical_payload",
28
+ "UnknownPayloadVersion",
29
+ "UnknownRecordType",
30
+ "unsigned_field_paths",
31
+ "RECORD_TYPE_TO_FAMILY",
32
+ ]
33
+
34
+
35
+ class UnknownPayloadVersion(Exception):
36
+ """No canonical function is registered for a record's (type, version)."""
37
+
38
+
39
+ class UnknownRecordType(Exception):
40
+ """A record's ``record_type`` names no family this verifier registers.
41
+
42
+ Such a record has no canonical payload here: reading it as a generic
43
+ ``AuditRecord`` would verify bytes sengol never signed under that name.
44
+ """
45
+
46
+ def __init__(self, record_type: Any) -> None:
47
+ super().__init__(f"record_type {record_type!r} is not registered in this verifier")
48
+ self.record_type = record_type
49
+
50
+
51
+ # Field names that are datetimes in every canonical function that reads them.
52
+ # Any other field is passed through as the raw JSON scalar/list/dict.
53
+ _DATETIME_FIELDS = frozenset(
54
+ {
55
+ "timestamp",
56
+ "period_from",
57
+ "period_to",
58
+ "certified_at",
59
+ "valid_until",
60
+ "purged_at",
61
+ "retired_at",
62
+ "decided_at",
63
+ "authorized_until",
64
+ }
65
+ )
66
+
67
+
68
+ class _JsonPassthrough:
69
+ """Wraps an already-JSON-shaped dict so ``.model_dump(mode="json")`` is a no-op.
70
+
71
+ ``eval_result`` is stored as the exact dict a real ``EvalResult.model_dump``
72
+ call produced, so returning it unchanged reproduces the same bytes.
73
+ """
74
+
75
+ def __init__(self, data: dict) -> None:
76
+ self._data = data
77
+
78
+ def model_dump(self, mode: str = "json") -> dict:
79
+ return self._data
80
+
81
+
82
+ class Record:
83
+ """Attribute view over one reconstructed record's raw JSON dict.
84
+
85
+ ``family`` names which canonical-function family signs this record's
86
+ bytes (e.g. "AuditRecord", "AgentCallRecord") — resolved once, at
87
+ construction, from the record's ``record_type`` string field. It is
88
+ ``None`` for a record whose ``record_type`` is not registered; such a
89
+ record has no canonical payload and ``canonical_payload`` raises
90
+ :exc:`UnknownRecordType`.
91
+ """
92
+
93
+ def __init__(self, raw: dict, family: str | None) -> None:
94
+ self._raw = raw
95
+ self.family = family
96
+
97
+ def __getattr__(self, name: str) -> Any:
98
+ if name not in self._raw:
99
+ return None
100
+ value = self._raw[name]
101
+ if value is not None and name in _DATETIME_FIELDS and isinstance(value, str):
102
+ return datetime.fromisoformat(value)
103
+ if name == "eval_result" and value is not None:
104
+ return _JsonPassthrough(value)
105
+ return value
106
+
107
+ @property
108
+ def payload_version(self) -> int:
109
+ return self._raw.get("payload_version") or 1
110
+
111
+ @property
112
+ def record_id(self) -> str:
113
+ return str(self._raw.get("record_id", ""))
114
+
115
+ @property
116
+ def hmac_signature(self) -> str:
117
+ return self._raw.get("hmac_signature", "")
118
+
119
+ @property
120
+ def key_id(self) -> str:
121
+ return self._raw.get("key_id", "")
122
+
123
+ @property
124
+ def tenant_id(self) -> str:
125
+ return self._raw.get("tenant_id", "")
126
+
127
+ @property
128
+ def agent_id(self) -> str:
129
+ return self._raw.get("agent_id", "")
130
+
131
+ @property
132
+ def sequence_number(self) -> int:
133
+ return self._raw.get("sequence_number", 0)
134
+
135
+ @property
136
+ def prev_hash(self) -> str:
137
+ return self._raw.get("prev_hash", "")
138
+
139
+ def canonical_payload(self) -> str:
140
+ if self.family is None:
141
+ raise UnknownRecordType(self._raw.get("record_type"))
142
+ return canonical_payload(self.family, self.payload_version, self)
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # record_type string -> canonical-function family name (sengol AUDIT_RECORD_TYPES).
147
+ # A record with no record_type field is base AuditRecord; one with a
148
+ # record_type missing from this map is refused by ``family_for``.
149
+ # ---------------------------------------------------------------------------
150
+
151
+ RECORD_TYPE_TO_FAMILY: dict[str, str] = {
152
+ "sengol.agent.call": "AgentCallRecord",
153
+ "sengol.agent.tier_change": "AgentTierChangeRecord",
154
+ "sengol.audit.certification_anchor": "CertificationAnchorRecord",
155
+ "sengol.audit.dataset_rows_reveal": "DatasetRowsRevealRecord",
156
+ "sengol.audit.tombstone": "TombstoneRecord",
157
+ "sengol.audit.trace_reveal": "TraceRevealRecord",
158
+ "sengol.authorization.decision": "AuthorizationDecisionRecord",
159
+ "sengol.behaviour.deviation": "BehaviourDeviationRecord",
160
+ "sengol.behaviour.lease": "AcceptedSetLease",
161
+ "sengol.call.started": "CallStartedRecord",
162
+ "sengol.certification.revocation": "CertificationRevocationRecord",
163
+ "sengol.certification.supersession": "CertificationSupersessionRecord",
164
+ "sengol.gateway.usage": "GatewayUsageRecord",
165
+ "sengol.guard.decision": "GuardDecisionRecord",
166
+ "sengol.judge.model_card": "JudgeModelCard",
167
+ "sengol.judge.promotion_decision": "PromotionDecisionRecord",
168
+ "sengol.lifecycle.deployment_authorization": "DeploymentAuthorizationRecord",
169
+ "sengol.quarantine.event": "AgentQuarantineRecord",
170
+ "sengol.sod.approval": "SoDApprovalRecord",
171
+ "sengol.sod.decision": "SoDDecisionRecord",
172
+ }
173
+
174
+
175
+ def family_for(raw: dict) -> str:
176
+ """The canonical-function family for a raw record dict.
177
+
178
+ A record with no ``record_type`` (or a null one) is the base
179
+ ``AuditRecord``. Any other value must be a key of
180
+ ``RECORD_TYPE_TO_FAMILY``; otherwise this raises :exc:`UnknownRecordType`
181
+ rather than falling back to ``AuditRecord``.
182
+
183
+ ``EvaluationRunRecord``, ``CertificationRecord``, ``GoldScoreRecord``,
184
+ ``SaturationEvent`` and the two signed regression records
185
+ (``ProductionFailureRecord``, ``RegressionCaseRecord``) carry no
186
+ ``record_type`` field at all — each lives outside the audit chain, in its
187
+ own table — so none belongs in ``RECORD_TYPE_TO_FAMILY``. A caller
188
+ reconstructing one of these passes its family to ``Record(raw, family)``
189
+ directly, as ``scripts/crosscheck_canonical.py`` does.
190
+ """
191
+ record_type = raw.get("record_type")
192
+ if record_type is None:
193
+ return "AuditRecord"
194
+ if isinstance(record_type, str) and record_type in RECORD_TYPE_TO_FAMILY:
195
+ return RECORD_TYPE_TO_FAMILY[record_type]
196
+ raise UnknownRecordType(record_type)
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # ADR-0019: one canonical rule signs every record. The payload is RFC 8785
201
+ # (JSON Canonicalization Scheme) over {"record": <fields>, "type": <family>},
202
+ # where <fields> is the record's JSON-mode field values minus the family's
203
+ # UNSIGNED_FIELDS (mirrors sengol/core/payload_registry.py verbatim — do not
204
+ # reintroduce a per-(family, version) function here).
205
+ # ---------------------------------------------------------------------------
206
+
207
+ #: Every payload_version each family accepts. Copied verbatim from
208
+ #: sengol/core/payload_registry.py::_VERSIONS. This only gates "is this
209
+ #: shape known" — every registered version canonicalizes by the one rule.
210
+ _VERSIONS: dict[str, tuple[int, ...]] = {
211
+ "AcceptedSetLease": (1,),
212
+ "AgentCallRecord": (1, 3, 4),
213
+ "AgentQuarantineRecord": (1,),
214
+ "AgentTierChangeRecord": (1,),
215
+ "AuditRecord": (1, 2, 3),
216
+ "AuthorizationDecisionRecord": (1, 2),
217
+ "BehaviourDeviationRecord": (1,),
218
+ "BehaviourManifestRecord": (1,),
219
+ "CallSetManifestRecord": (1,),
220
+ "CallStartedRecord": (1,),
221
+ "CertificationAnchorRecord": (1,),
222
+ "CertificationRecord": (2, 3, 4),
223
+ "CertificationRevocationRecord": (1,),
224
+ "CertificationSupersessionRecord": (1,),
225
+ "DatasetRowsRevealRecord": (1,),
226
+ "DeploymentAuthorizationRecord": (1,),
227
+ "EvaluationCaseRecord": (1,),
228
+ "EvaluationRunRecord": (2, 3, 4, 5, 6),
229
+ "GatewayUsageRecord": (1,),
230
+ "GoldScoreRecord": (1, 2),
231
+ "GuardDecisionRecord": (1,),
232
+ "JudgeModelCard": (1, 2),
233
+ "ProductionFailureRecord": (1,),
234
+ "PromotionDecisionRecord": (1, 2),
235
+ "RegressionCaseRecord": (1,),
236
+ "SaturationEvent": (1, 2),
237
+ "SoDApprovalRecord": (1,),
238
+ "SoDDecisionRecord": (1, 2, 3),
239
+ "TombstoneRecord": (1, 2),
240
+ "TraceRevealRecord": (1,),
241
+ }
242
+
243
+ #: The signature itself and the name of the backend that produced it. Every
244
+ #: family leaves these out; a family adds its own via `_UNSIGNED_FIELDS`.
245
+ ALWAYS_UNSIGNED_FIELDS = frozenset({"hmac_signature", "signing_backend"})
246
+
247
+ #: Unsigned on the base ``AuditRecord`` and so on every subtype: the
248
+ #: transport-only manifest sidecar, stripped before a record is stored.
249
+ _AUDIT_UNSIGNED_FIELDS = frozenset({"call_signature_manifests"})
250
+ #: Top-level UNSIGNED_FIELDS per family, mirroring sengol/core/types.py's
251
+ #: class attributes of the same name (ADR-0019). An ``AuditRecord`` family
252
+ #: also leaves out ``_AUDIT_UNSIGNED_FIELDS``; see ``_unsigned_fields``.
253
+ _UNSIGNED_FIELDS: dict[str, frozenset[str]] = {
254
+ "AuthorizationDecisionRecord": frozenset(
255
+ {"eval_result", "mcp_server_version", "model", "input_tokens", "output_tokens", "cost_usd"}
256
+ ),
257
+ "GuardDecisionRecord": frozenset({"eval_result"}),
258
+ "SaturationEvent": frozenset({"remediated_at"}),
259
+ "CertificationRecord": frozenset({"anchor_record_id", "evidence_pack_id"}),
260
+ }
261
+ #: UNSIGNED_FIELDS of the models nested inside a record, keyed by the path of
262
+ #: field names from the record root (list positions do not count). A nested
263
+ #: model's own exclusions apply only where the path names it, and only in the
264
+ #: families whose class holds that model at that path:
265
+ #: ``EvalScore`` (``reason`` is erasable text bound by the signed
266
+ #: ``reason_digest``; ``reason_status`` describes a read) sits at
267
+ #: ``eval_result.scores`` in every ``AuditRecord`` subtype, and ``CaseBinding``
268
+ #: (raw content bound by its signed ``config_hash`` and ``reference_hash``)
269
+ #: sits at ``bindings`` in ``RegressionCaseRecord`` alone.
270
+ _AUDIT_NESTED_UNSIGNED_FIELDS: dict[tuple[str, ...], frozenset[str]] = {
271
+ ("eval_result", "scores"): frozenset({"reason", "reason_status"}),
272
+ }
273
+ _NESTED_UNSIGNED_FIELDS: dict[str, dict[tuple[str, ...], frozenset[str]]] = {
274
+ "RegressionCaseRecord": {("bindings",): frozenset({"config", "reference"})},
275
+ }
276
+ #: Lists whose order carries no meaning; sorted by their own RFC 8785 bytes
277
+ #: before signing (ADR-0019), mirroring each family's UNORDERED_FIELDS.
278
+ _UNORDERED_FIELDS: dict[str, frozenset[str]] = {
279
+ "AcceptedSetLease": frozenset({"accepted_cert_ids"}),
280
+ "CertificationRecord": frozenset(
281
+ {"acknowledged_unversioned", "endpoint_equivalents", "environment_mounts", "supersedes"}
282
+ ),
283
+ "CertificationSupersessionRecord": frozenset({"supersedes"}),
284
+ "DatasetRowsRevealRecord": frozenset({"row_indexes"}),
285
+ "EvaluationCaseRecord": frozenset({"scores", "skipped"}),
286
+ "EvaluationRunRecord": frozenset({"evaluator_manifest"}),
287
+ "GuardDecisionRecord": frozenset({"policies", "policy_outcomes"}),
288
+ "SoDDecisionRecord": frozenset({"approval_record_ids"}),
289
+ "TombstoneRecord": frozenset({"purged_dataset_ids", "purged_trace_ids"}),
290
+ }
291
+
292
+
293
+ def _unsigned_fields(family: str) -> frozenset[str]:
294
+ unsigned = ALWAYS_UNSIGNED_FIELDS | _UNSIGNED_FIELDS.get(family, frozenset())
295
+ if _is_audit_family(family):
296
+ unsigned |= _AUDIT_UNSIGNED_FIELDS
297
+ return unsigned
298
+
299
+
300
+ def _is_audit_family(family: str) -> bool:
301
+ return family == "AuditRecord" or family in RECORD_TYPE_TO_FAMILY.values()
302
+
303
+
304
+ def nested_unsigned_fields(family: str) -> dict[tuple[str, ...], frozenset[str]]:
305
+ """The nested-model exclusions that apply to *family*, by path."""
306
+ if _is_audit_family(family):
307
+ return _AUDIT_NESTED_UNSIGNED_FIELDS
308
+ return _NESTED_UNSIGNED_FIELDS.get(family, {})
309
+
310
+
311
+ def _strip(
312
+ value: Any,
313
+ unsigned: frozenset[str],
314
+ nested: dict[tuple[str, ...], frozenset[str]],
315
+ path: tuple[str, ...] = (),
316
+ ) -> Any:
317
+ """Drop *unsigned* keys and every ``None`` value, recursing into dicts
318
+ and lists so a nested model's own exclusions apply where its path
319
+ from the record root names it."""
320
+ if isinstance(value, dict):
321
+ out = {}
322
+ for k, v in value.items():
323
+ if k in unsigned or v is None:
324
+ continue
325
+ sub = path + (k,)
326
+ out[k] = _strip(v, nested.get(sub, frozenset()), nested, sub)
327
+ return out
328
+ if isinstance(value, list):
329
+ return [_strip(v, unsigned, nested, path) for v in value]
330
+ return value
331
+
332
+
333
+ def _element_key(value: Any) -> bytes:
334
+ return rfc8785.dumps(value)
335
+
336
+
337
+ def canonical_payload(family: str, version: int, record: Any) -> str:
338
+ """Return the RFC 8785 canonical JSON string for *record* (ADR-0019).
339
+
340
+ Raises :exc:`UnknownPayloadVersion` if *version* is not registered for
341
+ *family*, and lets ``rfc8785.CanonicalizationError`` propagate for a
342
+ signed value with no RFC 8785 form (NaN, Infinity, an integer beyond
343
+ 2**53) — the same refusal real sengol makes.
344
+ """
345
+ if version not in _VERSIONS.get(family, ()):
346
+ raise UnknownPayloadVersion(
347
+ f"No canonical payload registered for {family} v{version}. "
348
+ f"Known versions: {_VERSIONS.get(family, ())}"
349
+ )
350
+ fields = _strip(record._raw, _unsigned_fields(family), nested_unsigned_fields(family))
351
+ for name in _UNORDERED_FIELDS.get(family, frozenset()):
352
+ if isinstance(fields.get(name), list):
353
+ fields[name] = sorted(fields[name], key=_element_key)
354
+ return rfc8785.dumps({"record": fields, "type": family}).decode("utf-8")
355
+
356
+
357
+ def unsigned_field_paths(record: Record) -> list[str]:
358
+ """The set fields of *record* that sit outside its signature, as sorted
359
+ dotted paths from the record root (list positions do not count).
360
+
361
+ A field is listed when its value is not ``None`` and either the family
362
+ leaves it out (the signature fields, the family's ``UNSIGNED_FIELDS``) or
363
+ the family's nested table names its path (``eval_result.scores.reason``).
364
+ A field inside an unsigned parent is not listed again. This walks the
365
+ record exactly as ``_strip`` does, so every path returned is absent from
366
+ ``canonical_payload``. A record with no family has no signature rule and
367
+ returns ``[]``.
368
+ """
369
+ if record.family is None:
370
+ return []
371
+ found: set[str] = set()
372
+
373
+ nested = nested_unsigned_fields(record.family)
374
+
375
+ def walk(value: Any, unsigned: frozenset[str], path: tuple[str, ...]) -> None:
376
+ if isinstance(value, dict):
377
+ for k, v in value.items():
378
+ if v is None:
379
+ continue
380
+ sub = path + (k,)
381
+ if k in unsigned:
382
+ found.add(".".join(sub))
383
+ else:
384
+ walk(v, nested.get(sub, frozenset()), sub)
385
+ elif isinstance(value, list):
386
+ for v in value:
387
+ walk(v, unsigned, path)
388
+
389
+ walk(record._raw, _unsigned_fields(record.family), ())
390
+ return sorted(found)