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/__init__.py +52 -0
- succession/acp.py +522 -0
- succession/acp_cli.py +133 -0
- succession/agent.py +288 -0
- succession/audit.py +470 -0
- succession/canonical.py +103 -0
- succession/catalog.py +571 -0
- succession/certificate.py +113 -0
- succession/chain.py +678 -0
- succession/cli.py +895 -0
- succession/data/__init__.py +1 -0
- succession/data/artifacts.json +1789 -0
- succession/data/base-sepolia.json +11 -0
- succession/dataroom.py +275 -0
- succession/demo.py +200 -0
- succession/demokeys.py +33 -0
- succession/envelope.py +140 -0
- succession/erc8004.py +603 -0
- succession/evaluator.py +459 -0
- succession/export.py +237 -0
- succession/fulfil.py +165 -0
- succession/importer.py +243 -0
- succession/marketplace.py +159 -0
- succession/mcp_server/__init__.py +309 -0
- succession/memory/__init__.py +23 -0
- succession/memory/base.py +172 -0
- succession/memory/credentials.py +135 -0
- succession/memory/sibyl.py +451 -0
- succession/merkle.py +226 -0
- succession/provenance.py +187 -0
- succession/publish.py +361 -0
- succession/redaction.py +238 -0
- succession/reputation.py +404 -0
- succession/scope.py +344 -0
- succession/seal.py +309 -0
- succession/seed.py +309 -0
- succession/settlement.py +543 -0
- succession/smp.py +386 -0
- succession/transfer.py +322 -0
- succession/valuation.py +306 -0
- succession_cli-0.2.0.dist-info/METADATA +56 -0
- succession_cli-0.2.0.dist-info/RECORD +44 -0
- succession_cli-0.2.0.dist-info/WHEEL +4 -0
- succession_cli-0.2.0.dist-info/entry_points.txt +4 -0
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
|
succession/canonical.py
ADDED
|
@@ -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 "")
|