succession-cli 0.2.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.
succession/audit.py ADDED
@@ -0,0 +1,470 @@
1
+ """Check every claim this project makes about itself, and say so out loud.
2
+
3
+ The argument Succession makes is that its assertions are checkable. This is the
4
+ command that checks them. It takes no repository, no frontend and no network:
5
+ it builds a memory, sells it to itself, and measures what happened.
6
+
7
+ Every check here computes rather than asserts. Three of them exist because an
8
+ audit found the corresponding claim resting on nothing:
9
+
10
+ * "newest first" was verified only by grepping the documentation string that
11
+ says "newest first". Here the records carry known timestamps and the halves
12
+ are compared.
13
+ * "the valuation is re-derivable by hand" had no test that re-derived anything.
14
+ Here each factor is recomputed from the inputs it published and compared to
15
+ the multiplier it reported.
16
+ * "a non-transferable record never reaches the tree" was inferred from the
17
+ record's absence from the package payload. Absence from a payload is not
18
+ absence from a hash, so here the tree is built twice, with and without, and
19
+ the roots are required to be identical: if the withheld record had reached
20
+ the tree in any form, the root would move.
21
+
22
+ A check that cannot run says so and is reported as `skipped`, never as passed.
23
+ The exit code is non-zero if anything failed.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import tempfile
30
+ from dataclasses import dataclass, field
31
+ from datetime import datetime, timedelta, timezone
32
+ from decimal import Decimal
33
+ from pathlib import Path
34
+ from typing import Any, Callable
35
+
36
+ __all__ = ["Check", "run_audit", "CHECKS"]
37
+
38
+
39
+ @dataclass
40
+ class Check:
41
+ """One claim, and what became of it."""
42
+
43
+ name: str
44
+ claim: str
45
+ status: str = "pending" # passed | failed | skipped
46
+ detail: str = ""
47
+ evidence: dict[str, Any] = field(default_factory=dict)
48
+
49
+ def to_dict(self) -> dict[str, Any]:
50
+ return {
51
+ "name": self.name,
52
+ "claim": self.claim,
53
+ "status": self.status,
54
+ "detail": self.detail,
55
+ "evidence": self.evidence,
56
+ }
57
+
58
+
59
+ def _seed(memory: Any, *, count: int = 12) -> None:
60
+ """A small tenant with known shape: public and private, transferable or not."""
61
+ from .redaction import Consent, Sensitivity, mark
62
+
63
+ client = memory.client
64
+ client.set_entity(
65
+ "identity", "erc8004:84532:0417",
66
+ mark({"name": "Audit Operator", "role": "Freight"},
67
+ sensitivity=Sensitivity.PUBLIC),
68
+ )
69
+ for i in range(count):
70
+ client.set_entity(
71
+ "relationship", f"counterparty-{i:02d}",
72
+ mark({"company": f"Counterparty {i:02d}", "terms": f"net-{30 + i}"},
73
+ sensitivity=Sensitivity.PRIVATE),
74
+ )
75
+ client.write_event(evaluated={"cp": f"counterparty-{i:02d}"},
76
+ acted={"quoted": 1000 + i})
77
+ client.set_entity(
78
+ "preference", "lane-bias",
79
+ mark({"prefers": "midwest"}, sensitivity=Sensitivity.PRIVATE),
80
+ )
81
+ client.set_entity(
82
+ "commitment", "open-quote",
83
+ mark({"owed": "quote by friday"}, sensitivity=Sensitivity.PRIVATE),
84
+ )
85
+ client.set_reference(
86
+ "escalation",
87
+ mark({"rule": "escalate over 40k"}, sensitivity=Sensitivity.PRIVATE),
88
+ )
89
+ # The record that must never move, at any price, for any buyer.
90
+ client.set_entity(
91
+ "relationship", "sealed-counterparty",
92
+ mark({"company": "Never Transfers Ltd", "secret": "aperture-defense"},
93
+ sensitivity=Sensitivity.PRIVATE, consent=Consent.WITHHELD),
94
+ )
95
+
96
+
97
+ # --- the checks ----------------------------------------------------------
98
+
99
+
100
+ def check_categories_transfer(ctx: dict[str, Any]) -> Check:
101
+ check = Check(
102
+ "categories-transfer",
103
+ "All six memory directories survive a sale with identical Merkle subroots.",
104
+ )
105
+ from .export import export_tenant
106
+ from .importer import import_package
107
+ from .memory.sibyl import open_tenant
108
+ from .smp import DATA_CATEGORIES
109
+
110
+ source = ctx["source"]
111
+ exported = export_tenant(
112
+ source, agent_identity=ctx["agent"], private_key=ctx["key"]
113
+ )
114
+ with tempfile.TemporaryDirectory() as tmp:
115
+ sink = open_tenant(Path(tmp) / "buyer.db", "audit-buyer")
116
+ import_package(
117
+ exported.package, sink,
118
+ committed_root=exported.root_hex, expected_signer=ctx["signer"],
119
+ )
120
+ landed = export_tenant(
121
+ sink, agent_identity=ctx["agent"], private_key=ctx["key"]
122
+ )
123
+
124
+ sent = {c["category"]: c for c in exported.package.integrity["categories"]}
125
+ got = {c["category"]: c for c in landed.package.integrity["categories"]}
126
+
127
+ rows, bad = {}, []
128
+ for category in DATA_CATEGORIES:
129
+ a, b = sent.get(category), got.get(category)
130
+ if a is None or b is None:
131
+ rows[category] = "absent"
132
+ continue
133
+ same = a["subroot"] == b["subroot"] and a["leaf_count"] == b["leaf_count"]
134
+ rows[category] = f"{a['leaf_count']} leaves, {'identical' if same else 'CHANGED'}"
135
+ if not same:
136
+ bad.append(category)
137
+
138
+ carrying = [c for c in DATA_CATEGORIES if c in sent and sent[c]["leaf_count"] > 0]
139
+ check.evidence = {"per_category": rows, "carrying_records": len(carrying)}
140
+ if bad:
141
+ check.status, check.detail = "failed", f"subroots moved: {', '.join(bad)}"
142
+ elif len(carrying) < len(DATA_CATEGORIES):
143
+ missing = [c for c in DATA_CATEGORIES if c not in carrying]
144
+ check.status = "failed"
145
+ check.detail = f"no records in {', '.join(missing)}, so nothing was proved for them"
146
+ else:
147
+ check.status = "passed"
148
+ check.detail = f"all {len(DATA_CATEGORIES)} directories re-derived identically"
149
+ return check
150
+
151
+
152
+ def check_withheld_leaves_no_trace(ctx: dict[str, Any]) -> Check:
153
+ check = Check(
154
+ "withheld-absent-from-tree",
155
+ "A record marked non-transferable is absent from the Merkle tree, not just "
156
+ "from the payload.",
157
+ )
158
+ import copy
159
+
160
+ from .export import build_package
161
+
162
+ package, report = build_package(ctx["source"])
163
+
164
+ blob = json.dumps(package.to_dict(), sort_keys=True)
165
+ leaked = "aperture-defense" in blob or "Never Transfers Ltd" in blob
166
+ names = {
167
+ r.get("name")
168
+ for records in package.data.values()
169
+ for r in records
170
+ if isinstance(r, dict)
171
+ }
172
+ in_payload = "sealed-counterparty" in names
173
+ actual_root = "0x" + package.tree().root.hex()
174
+
175
+ # Absence from a payload is not absence from a hash, and that distinction is
176
+ # the whole claim. So: put the record back, rebuild, and require the root to
177
+ # move. A root that is sensitive to the record's presence cannot already
178
+ # contain it. Comparing two separately seeded stores would not show this —
179
+ # their journal timestamps differ, so the roots differ for reasons that have
180
+ # nothing to do with consent.
181
+ mutated = copy.deepcopy(package)
182
+ relationships = mutated.data.get("relationships") or []
183
+ if relationships:
184
+ smuggled = copy.deepcopy(relationships[0])
185
+ smuggled["name"] = "sealed-counterparty"
186
+ smuggled["body"] = {"company": "Never Transfers Ltd", "secret": "aperture-defense"}
187
+ mutated.data["relationships"] = [*relationships, smuggled]
188
+ mutated_root = "0x" + mutated.tree().root.hex()
189
+
190
+ check.evidence = {
191
+ "withheld_without_consent": report.withheld_without_consent,
192
+ "body_appears_in_package": leaked,
193
+ "name_appears_in_payload": in_payload,
194
+ "root_as_built": actual_root,
195
+ "root_if_the_record_were_included": mutated_root,
196
+ }
197
+ if leaked or in_payload:
198
+ check.status, check.detail = "failed", "the withheld record appears in the package"
199
+ elif report.withheld_without_consent < 1:
200
+ check.status, check.detail = "failed", "the fixture withheld nothing, so nothing was proved"
201
+ elif not relationships:
202
+ check.status, check.detail = "failed", "no relationship records to test against"
203
+ elif actual_root == mutated_root:
204
+ check.status = "failed"
205
+ check.detail = (
206
+ "the root does not change when the record is added, so the tree "
207
+ "cannot be said to exclude it"
208
+ )
209
+ else:
210
+ check.status = "passed"
211
+ check.detail = (
212
+ "absent from the payload, and the root moves if it is added back, "
213
+ "so it is absent from the tree"
214
+ )
215
+ return check
216
+
217
+
218
+ def check_preview_carries_no_bodies(ctx: dict[str, Any]) -> Check:
219
+ check = Check(
220
+ "preview-has-no-bodies",
221
+ "The pre-purchase data room carries counts and no record bodies.",
222
+ )
223
+ from .dataroom import build_preview
224
+
225
+ preview = build_preview(ctx["source"], agent_identity=ctx["agent"])
226
+ blob = json.dumps(preview.to_dict(), sort_keys=True)
227
+
228
+ # Every distinctive string from a non-public record, swept against the whole
229
+ # serialised preview. Short strings are skipped: a three-letter token
230
+ # colliding by chance would be noise, not a leak.
231
+ leaks = []
232
+ for value in ("Counterparty 00", "net-30", "midwest", "quote by friday",
233
+ "escalate over 40k", "aperture-defense", "Never Transfers Ltd"):
234
+ if value in blob:
235
+ leaks.append(value)
236
+
237
+ check.evidence = {
238
+ "preview_bytes": len(blob),
239
+ "strings_swept": 7,
240
+ "leaked": leaks,
241
+ "public_counterparties_named": list(preview.public_counterparties),
242
+ }
243
+ if leaks:
244
+ check.status, check.detail = "failed", f"private content in the preview: {leaks}"
245
+ else:
246
+ check.status = "passed"
247
+ check.detail = "no private record body appears in the published preview"
248
+ return check
249
+
250
+
251
+ def check_valuation_re_derives(ctx: dict[str, Any]) -> Check:
252
+ check = Check(
253
+ "valuation-re-derives",
254
+ "Every valuation factor can be recomputed from the inputs it publishes.",
255
+ )
256
+ from .valuation import value_tenant
257
+
258
+ valuation = value_tenant(ctx["source"])
259
+ body = valuation.to_dict()
260
+
261
+ product = Decimal(body["base_price"])
262
+ for factor in body["factors"]:
263
+ product *= Decimal(factor["value"])
264
+ stated = Decimal(body["amount"])
265
+ # Quantized to cents by the valuation, so compare at that resolution.
266
+ agrees = abs(product - stated) <= Decimal("0.01")
267
+
268
+ # And each factor has to show its working, not merely a number.
269
+ undocumented = [
270
+ f["name"] for f in body["factors"] if not f.get("inputs") or not f.get("explanation")
271
+ ]
272
+
273
+ check.evidence = {
274
+ "formula": body["formula"],
275
+ "base_price": body["base_price"],
276
+ "factors": {f["name"]: f["value"] for f in body["factors"]},
277
+ "recomputed": str(product.quantize(Decimal("0.01"))),
278
+ "reported": body["amount"],
279
+ "excluded_terms": sorted(body["excluded"]),
280
+ }
281
+ if not agrees:
282
+ check.status = "failed"
283
+ check.detail = f"recomputed {product} but the valuation reports {stated}"
284
+ elif undocumented:
285
+ check.status = "failed"
286
+ check.detail = f"factors without inputs or explanation: {undocumented}"
287
+ else:
288
+ check.status = "passed"
289
+ check.detail = "the product of the published factors equals the published figure"
290
+ return check
291
+
292
+
293
+ def check_newest_first_selection(ctx: dict[str, Any]) -> Check:
294
+ check = Check(
295
+ "partial-takes-newest",
296
+ "A percentage sale takes the newest records, not an arbitrary half.",
297
+ )
298
+ from .scope import SaleScope
299
+
300
+ now = datetime.now(timezone.utc)
301
+ records = [
302
+ {
303
+ "kind": "entity",
304
+ "category": "relationship",
305
+ "name": f"cp-{i:02d}",
306
+ "ts": (now - timedelta(days=100 - i)).strftime("%Y-%m-%dT%H:%M:%SZ"),
307
+ "body": {"n": i},
308
+ }
309
+ for i in range(10)
310
+ ]
311
+ scope = SaleScope.from_percentages({"relationships": 50})
312
+ kept, _withheld = scope.resolve(records)
313
+ taken = sorted(int(r["name"].split("-")[1]) for r in kept)
314
+
315
+ # Records 0..9 age from oldest to newest, so a newest-first half is 5..9.
316
+ expected = [5, 6, 7, 8, 9]
317
+ check.evidence = {
318
+ "seeded": 10,
319
+ "requested_percent": 50,
320
+ "taken_indices": taken,
321
+ "expected_newest_half": expected,
322
+ }
323
+ if taken == expected:
324
+ check.status = "passed"
325
+ check.detail = "the newer half was selected, by timestamp"
326
+ else:
327
+ check.status = "failed"
328
+ check.detail = f"expected the newest half {expected}, got {taken}"
329
+ return check
330
+
331
+
332
+ def check_the_seal_holds(ctx: dict[str, Any]) -> Check:
333
+ check = Check(
334
+ "seal-rejects-writes",
335
+ "After settlement the seller's tenant rejects every write, and there is no unseal.",
336
+ )
337
+ from .seal import SealRegistry, TenantSealed, guard
338
+
339
+ with tempfile.TemporaryDirectory() as tmp:
340
+ registry = SealRegistry(Path(tmp) / "seals.db")
341
+ registry.seal("audit-seller", reason="audit")
342
+ guarded = guard(ctx["source"], registry)
343
+
344
+ attempts, rejected = [], []
345
+ probes: list[tuple[str, Callable[[], Any]]] = [
346
+ ("set_entity", lambda: guarded.client.set_entity(
347
+ "relationship", "post-seal", {"company": "After"})),
348
+ ("write_event", lambda: guarded.client.write_event(
349
+ evaluated={"x": 1}, acted={"y": 2})),
350
+ ("set_state", lambda: guarded.client.set_state("s", {"a": 1})),
351
+ ("set_reference", lambda: guarded.client.set_reference("r", {"a": 1})),
352
+ ("archive_entity", lambda: guarded.client.archive_entity(
353
+ "relationship", "counterparty-00")),
354
+ ]
355
+ for name, probe in probes:
356
+ attempts.append(name)
357
+ try:
358
+ probe()
359
+ except TenantSealed:
360
+ rejected.append(name)
361
+ except Exception: # noqa: BLE001 - any refusal is a refusal
362
+ rejected.append(name)
363
+
364
+ has_unseal = hasattr(SealRegistry, "unseal")
365
+ check.evidence = {
366
+ "write_paths_attempted": attempts,
367
+ "rejected": rejected,
368
+ "registry_exposes_unseal": has_unseal,
369
+ }
370
+ if has_unseal:
371
+ check.status, check.detail = "failed", "the registry exposes an unseal operation"
372
+ elif len(rejected) != len(attempts):
373
+ allowed = sorted(set(attempts) - set(rejected))
374
+ check.status, check.detail = "failed", f"writes still permitted: {allowed}"
375
+ else:
376
+ check.status = "passed"
377
+ check.detail = f"all {len(attempts)} write paths rejected; no unseal exists"
378
+ return check
379
+
380
+
381
+ def check_manifests_match_the_chain(ctx: dict[str, Any]) -> Check:
382
+ check = Check(
383
+ "published-manifests-match-chain",
384
+ "Every published Merkle root equals the commitment recorded on chain.",
385
+ )
386
+ base = ctx.get("marketplace")
387
+ if not base:
388
+ check.status = "skipped"
389
+ check.detail = "no marketplace given; pass --marketplace to check live listings"
390
+ return check
391
+
392
+ from .marketplace import MarketplaceError, get
393
+
394
+ try:
395
+ body = get(base, "/api/marketplace")
396
+ except MarketplaceError as exc:
397
+ check.status, check.detail = "skipped", f"marketplace unreachable: {exc}"
398
+ return check
399
+
400
+ rows = [r for r in body.get("listings", []) if not r.get("demo")]
401
+ compared, disagreed = 0, []
402
+ for row in rows:
403
+ manifest = row.get("integrity") or {}
404
+ root = manifest.get("root")
405
+ if not root:
406
+ continue
407
+ compared += 1
408
+ if root.lower() != row["listing"]["hash_commitment"].lower():
409
+ disagreed.append(row["listing"]["listing_id"])
410
+
411
+ check.evidence = {
412
+ "marketplace": base,
413
+ "real_listings": len(rows),
414
+ "with_published_manifest": compared,
415
+ "disagreeing": disagreed,
416
+ }
417
+ if disagreed:
418
+ check.status = "failed"
419
+ check.detail = f"manifest disagrees with the chain for {', '.join(disagreed)}"
420
+ elif compared == 0:
421
+ check.status = "skipped"
422
+ check.detail = f"{len(rows)} live listings, none with a published manifest"
423
+ else:
424
+ check.status = "passed"
425
+ check.detail = f"{compared} published roots equal their on-chain commitment"
426
+ return check
427
+
428
+
429
+ CHECKS: tuple[Callable[[dict[str, Any]], Check], ...] = (
430
+ check_categories_transfer,
431
+ check_withheld_leaves_no_trace,
432
+ check_preview_carries_no_bodies,
433
+ check_valuation_re_derives,
434
+ check_newest_first_selection,
435
+ check_the_seal_holds,
436
+ check_manifests_match_the_chain,
437
+ )
438
+
439
+
440
+ def run_audit(*, marketplace: str | None = None) -> list[Check]:
441
+ """Build a memory, sell it to itself, and measure every claim."""
442
+ from eth_account import Account
443
+
444
+ from .memory.sibyl import open_tenant
445
+
446
+ key = "0x" + "27" * 32
447
+ with tempfile.TemporaryDirectory() as tmp:
448
+ source = open_tenant(Path(tmp) / "seller.db", "audit-seller")
449
+ _seed(source)
450
+ ctx = {
451
+ "source": source,
452
+ "agent": "erc8004:84532:0417",
453
+ "key": key,
454
+ "signer": Account.from_key(key).address,
455
+ "marketplace": marketplace,
456
+ }
457
+ results = []
458
+ for run in CHECKS:
459
+ try:
460
+ results.append(run(ctx))
461
+ except Exception as exc: # noqa: BLE001 - a crashed check is a failed one
462
+ results.append(
463
+ Check(
464
+ run.__name__.replace("check_", "").replace("_", "-"),
465
+ "(the check itself raised)",
466
+ status="failed",
467
+ detail=f"{type(exc).__name__}: {exc}",
468
+ )
469
+ )
470
+ return results
@@ -0,0 +1,103 @@
1
+ """Canonical, deterministic serialization.
2
+
3
+ Hashing a memory store is only meaningful if two honest parties who hold the
4
+ same logical content always produce the same bytes. Everything that reaches the
5
+ Merkle tree goes through this module first.
6
+
7
+ The rules, in full:
8
+
9
+ * Objects are emitted with keys sorted by Unicode code point.
10
+ * No insignificant whitespace (``separators=(",", ":")``).
11
+ * Text stays UTF-8 (``ensure_ascii=False``) and is NFC-normalized, so two
12
+ visually identical strings that differ only in Unicode composition hash the
13
+ same.
14
+ * Floats are rejected outright. JSON has no canonical float rendering that
15
+ survives a round trip through every language's parser, and a memory store has
16
+ no legitimate need for one — an agent that wants a number with a fractional
17
+ part should carry it as a string or a scaled integer.
18
+ * Entities sort by ``(category, name)``; journal events sort by ``(ts, id)``.
19
+ ``id`` breaks ties because two events written in the same millisecond are
20
+ common and their insertion order is not stable across a re-import.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import unicodedata
27
+ from typing import Any
28
+
29
+ __all__ = [
30
+ "canonical_bytes",
31
+ "canonical_json",
32
+ "normalize",
33
+ "entity_sort_key",
34
+ "event_sort_key",
35
+ "CanonicalizationError",
36
+ ]
37
+
38
+
39
+ class CanonicalizationError(ValueError):
40
+ """A value cannot be canonicalized deterministically."""
41
+
42
+
43
+ def normalize(value: Any) -> Any:
44
+ """Recursively coerce ``value`` into canonicalizable form.
45
+
46
+ Rejects anything whose serialization is not stable across languages.
47
+ """
48
+ if value is None or isinstance(value, bool):
49
+ return value
50
+ if isinstance(value, int):
51
+ return value
52
+ if isinstance(value, float):
53
+ raise CanonicalizationError(
54
+ "floats are not canonicalizable; carry the value as a string or a "
55
+ f"scaled integer instead (got {value!r})"
56
+ )
57
+ if isinstance(value, str):
58
+ # NFC so "é" (U+00E9) and "é" hash identically.
59
+ return unicodedata.normalize("NFC", value)
60
+ if isinstance(value, (list, tuple)):
61
+ # List order is content: it is preserved, not sorted.
62
+ return [normalize(v) for v in value]
63
+ if isinstance(value, dict):
64
+ out: dict[str, Any] = {}
65
+ for k, v in value.items():
66
+ if not isinstance(k, str):
67
+ raise CanonicalizationError(
68
+ f"object keys must be strings, got {type(k).__name__}"
69
+ )
70
+ key = unicodedata.normalize("NFC", k)
71
+ if key in out:
72
+ raise CanonicalizationError(
73
+ f"duplicate key after NFC normalization: {key!r}"
74
+ )
75
+ out[key] = normalize(v)
76
+ return out
77
+ raise CanonicalizationError(
78
+ f"value of type {type(value).__name__} is not JSON-canonicalizable"
79
+ )
80
+
81
+
82
+ def canonical_json(value: Any) -> str:
83
+ """Return the canonical JSON text for ``value``."""
84
+ return json.dumps(
85
+ normalize(value),
86
+ sort_keys=True,
87
+ separators=(",", ":"),
88
+ ensure_ascii=False,
89
+ allow_nan=False,
90
+ )
91
+
92
+
93
+ def canonical_bytes(value: Any) -> bytes:
94
+ """Return the canonical UTF-8 bytes for ``value`` — what actually gets hashed."""
95
+ return canonical_json(value).encode("utf-8")
96
+
97
+
98
+ def entity_sort_key(entity: dict[str, Any]) -> tuple[str, str]:
99
+ return (entity.get("category") or "", entity.get("name") or "")
100
+
101
+
102
+ def event_sort_key(event: dict[str, Any]) -> tuple[str, str]:
103
+ return (event.get("ts") or "", event.get("id") or "")