gabriel / musehub public
push_validator.py python
139 lines 6.2 KB
Raw
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