push_validator.py
python
sha256:9b711047e27df5ac91681c74aadfb0e31f69ffd4269932ea52f0c113764d8c0a
docs(phase-03): rewrite Domain Protocol — AddressedMergePlu…
Sonnet 4.6
minor
⚠ breaking
25 days ago
| 1 | """Hub-side enforcement for identity-domain pushes. |
| 2 | |
| 3 | Called by wire_push before any objects are persisted. |
| 4 | Validates the full committed state against all three invariants. |
| 5 | |
| 6 | I1 Acyclicity — hard error (push rejected) |
| 7 | I2 Root distance — warning (push accepted, node annotated) |
| 8 | I3 Authorization — hard error (push rejected) |
| 9 | |
| 10 | Authorization rules |
| 11 | ------------------- |
| 12 | spawns(from, to) → from_handle must appear in authorized_by |
| 13 | member_of(member, org) → quorum-many CURRENT org members must appear in authorized_by |
| 14 | "current" = another identity with a member_of edge pointing at the same org |
| 15 | quorum threshold comes from the org IdentityRecord.quorum field |
| 16 | Exception: if the org has NO prior members in the snapshot, the joining |
| 17 | identity may self-authorize (bootstrap / founding member pattern). |
| 18 | """ |
| 19 | |
| 20 | from dataclasses import dataclass, field |
| 21 | |
| 22 | from .cycle import CycleDetector, CycleError |
| 23 | from .dag import EdgeType, GraphEdge, IdentityDAG, NodeType |
| 24 | from .depth import RootDistanceIndex |
| 25 | |
| 26 | @dataclass |
| 27 | class ValidationResult: |
| 28 | valid: bool |
| 29 | errors: list[str] = field(default_factory=list) |
| 30 | warnings: list[str] = field(default_factory=list) |
| 31 | |
| 32 | class IdentityPushValidator: |
| 33 | """Validate the full committed identity state before a push is accepted.""" |
| 34 | |
| 35 | def validate( |
| 36 | self, |
| 37 | identities: list[dict], |
| 38 | relationships: list[dict], |
| 39 | ) -> ValidationResult: |
| 40 | errors: list[str] = [] |
| 41 | warnings: list[str] = [] |
| 42 | |
| 43 | # ── Build NodeType map ──────────────────────────────────────────────── |
| 44 | _type_map: dict[str, str] = {rec["handle"]: rec["type"] for rec in identities} |
| 45 | _quorum_map: dict[str, int] = { |
| 46 | rec["handle"]: rec["quorum"] |
| 47 | for rec in identities |
| 48 | if rec["type"] == "org" and rec.get("quorum") is not None |
| 49 | } |
| 50 | |
| 51 | # ── I1 — Acyclicity ─────────────────────────────────────────────────── |
| 52 | dag = IdentityDAG.empty() |
| 53 | for rec in identities: |
| 54 | nt = NodeType(rec["type"]) |
| 55 | dag.add_node(rec["handle"], nt) |
| 56 | |
| 57 | i1_bad: set[str] = set() |
| 58 | for rel in relationships: |
| 59 | frm = rel["from_handle"] |
| 60 | to = rel["to_handle"] |
| 61 | edge = EdgeType(rel["edge_type"]) |
| 62 | try: |
| 63 | CycleDetector(dag).assert_no_cycle(frm, to, edge) |
| 64 | dag.add_edge(frm, to) |
| 65 | except CycleError: |
| 66 | errors.append( |
| 67 | f"I1 violation: adding {frm!r} → {to!r} ({rel['edge_type']}) " |
| 68 | f"would introduce a cycle" |
| 69 | ) |
| 70 | i1_bad.add((frm, to, rel["edge_type"])) |
| 71 | |
| 72 | # ── I2 — Root distance ──────────────────────────────────────────────── |
| 73 | idx = RootDistanceIndex.build(dag) |
| 74 | for rec in identities: |
| 75 | h = rec["handle"] |
| 76 | if idx.distance(h) is None: |
| 77 | warnings.append( |
| 78 | f"I2 warning: {h!r} ({rec['type']}) has no path to any human root — " |
| 79 | f"it is an orphaned node" |
| 80 | ) |
| 81 | |
| 82 | # ── I3 — Authorization ──────────────────────────────────────────────── |
| 83 | # member_of relationships are processed in input order, maintaining a |
| 84 | # running "already-committed members" set per org. This mirrors the |
| 85 | # append-only event log: each relationship sees the members who were |
| 86 | # committed before it, not those added by later relationships in the |
| 87 | # same push. This naturally solves the bootstrap problem: the first |
| 88 | # member of an empty org self-authorizes; subsequent members need |
| 89 | # min(quorum, |prior|) existing signatures. |
| 90 | committed_members: dict[str, set[str]] = {} # org → members validated so far |
| 91 | |
| 92 | for rel in relationships: |
| 93 | if (rel["from_handle"], rel["to_handle"], rel["edge_type"]) in i1_bad: |
| 94 | continue |
| 95 | |
| 96 | signers = {s["signer"] for s in rel.get("authorized_by", [])} |
| 97 | frm = rel["from_handle"] |
| 98 | to = rel["to_handle"] |
| 99 | edge = rel["edge_type"] |
| 100 | |
| 101 | if edge == "spawns": |
| 102 | if frm not in signers: |
| 103 | errors.append( |
| 104 | f"I3 violation: spawns({frm!r} → {to!r}) must be authorized " |
| 105 | f"by {frm!r} — found signers: {sorted(signers)}" |
| 106 | ) |
| 107 | |
| 108 | elif edge == "member_of": |
| 109 | org_h = to |
| 110 | quorum = _quorum_map.get(org_h, 1) |
| 111 | prior = committed_members.get(org_h, set()) |
| 112 | |
| 113 | if not prior: |
| 114 | # Founding member — only self-authorization required |
| 115 | if frm not in signers: |
| 116 | errors.append( |
| 117 | f"I3 violation: member_of({frm!r} → {org_h!r}) " |
| 118 | f"bootstrap join must be self-authorized — no signature from {frm!r}" |
| 119 | ) |
| 120 | else: |
| 121 | # Need min(quorum, |prior|) existing member signatures |
| 122 | needed = min(quorum, len(prior)) |
| 123 | authorizing = signers & prior |
| 124 | if len(authorizing) < needed: |
| 125 | errors.append( |
| 126 | f"I3 violation: member_of({frm!r} → {org_h!r}) requires " |
| 127 | f"{needed} member signature(s) (quorum={quorum}, prior={len(prior)}), " |
| 128 | f"got {len(authorizing)} from {sorted(prior)}" |
| 129 | ) |
| 130 | |
| 131 | # Advance the running member set regardless of auth outcome so |
| 132 | # subsequent relationships see the correct prior state. |
| 133 | committed_members.setdefault(org_h, set()).add(frm) |
| 134 | |
| 135 | return ValidationResult( |
| 136 | valid=len(errors) == 0, |
| 137 | errors=errors, |
| 138 | warnings=warnings, |
| 139 | ) |
File History
1 commit
sha256:9b711047e27df5ac91681c74aadfb0e31f69ffd4269932ea52f0c113764d8c0a
docs(phase-03): rewrite Domain Protocol — AddressedMergePlu…
Sonnet 4.6
minor
⚠
25 days ago