gabriel / musehub public
push_validator.py python
181 lines 8.5 KB
Raw
sha256:066f0f58448cc0553bff3c845cbb8e1f7a6bdecf53fce6bc5c2ff5e0ddad3d71 feat: wire IdentityPushValidator into the real push pipelin… Sonnet 5 minor ⚠ breaking 2 days ago
1 """Hub-side enforcement for identity-domain pushes.
2
3 Called by wire_push_unpack_mpack (musehub/services/musehub_wire_push.py)
4 before the push transaction is committed — validates the full identities/
5 relationships snapshot reconstructed from the pushed commit's manifest
6 against all three invariants, for identity-domain repos only. Confirmed
7 and wired in by musehub#230 (2026-10-22): prior to that ticket this
8 docstring made the same claim while the class had zero production call
9 sites — see musehub#230 for the audit and musehub#227's now-corrected
10 comment thread for the retraction of that issue's "Definition A is real,
11 wired in" claim, which was itself inaccurate at the time it was written.
12
13 I1 Acyclicity — hard error (push rejected)
14 I2 Root distance — warning for GRACE_PERIOD_DAYS, then hard error (musehub#226)
15 I3 Authorization — hard error (push rejected)
16
17 I2 policy (musehub#226): a node with no path to any human root is a
18 legitimate transient state for up to GRACE_PERIOD_DAYS (covers
19 mid-construction identities and bulk-import-then-wire-relationships-later
20 workflows) — warning only, push accepted. Past that grace period, still
21 orphaned, it becomes a hard error — push rejected. Age is measured from
22 the identity's own `registered_at` timestamp, not from when it first
23 became orphaned (an identity is orphaned from the moment it's registered
24 until something gives it a path to a root).
25
26 This is the canonical I3 definition (musehub#227). musehub/graph/quorum.py
27 implements a different, unrelated mechanism (recursive org-vote quorum
28 tallying) that previously also claimed the "I3" label — that collision is
29 now resolved; quorum.py's docstring no longer uses the label.
30
31 Authorization rules
32 -------------------
33 spawns(from, to) → from_handle must appear in authorized_by
34 member_of(member, org) → quorum-many CURRENT org members must appear in authorized_by
35 "current" = another identity with a member_of edge pointing at the same org
36 quorum threshold comes from the org IdentityRecord.quorum field
37 Exception: if the org has NO prior members in the snapshot, the joining
38 identity may self-authorize (bootstrap / founding member pattern).
39 """
40
41 from dataclasses import dataclass, field
42 from datetime import datetime, timedelta, timezone
43
44 from .cycle import CycleDetector, CycleError
45 from .dag import EdgeType, GraphEdge, IdentityDAG, NodeType
46 from .depth import RootDistanceIndex
47
48 # musehub#226 — I2 grace period before an orphaned node becomes a hard error.
49 GRACE_PERIOD_DAYS = 30
50
51 @dataclass
52 class ValidationResult:
53 valid: bool
54 errors: list[str] = field(default_factory=list)
55 warnings: list[str] = field(default_factory=list)
56
57 def _parse_registered_at(value: str) -> datetime:
58 return datetime.fromisoformat(value.replace("Z", "+00:00"))
59
60 class IdentityPushValidator:
61 """Validate the full committed identity state before a push is accepted."""
62
63 def validate(
64 self,
65 identities: list[dict],
66 relationships: list[dict],
67 *,
68 now: datetime | None = None,
69 ) -> ValidationResult:
70 if now is None:
71 now = datetime.now(timezone.utc)
72 errors: list[str] = []
73 warnings: list[str] = []
74
75 # ── Build NodeType map ────────────────────────────────────────────────
76 _type_map: dict[str, str] = {rec["handle"]: rec["type"] for rec in identities}
77 _quorum_map: dict[str, int] = {
78 rec["handle"]: rec["quorum"]
79 for rec in identities
80 if rec["type"] == "org" and rec.get("quorum") is not None
81 }
82
83 # ── I1 — Acyclicity ───────────────────────────────────────────────────
84 dag = IdentityDAG.empty()
85 for rec in identities:
86 nt = NodeType(rec["type"])
87 dag.add_node(rec["handle"], nt)
88
89 i1_bad: set[str] = set()
90 for rel in relationships:
91 frm = rel["from_handle"]
92 to = rel["to_handle"]
93 edge = EdgeType(rel["edge_type"])
94 try:
95 CycleDetector(dag).assert_no_cycle(frm, to, edge)
96 dag.add_edge(frm, to)
97 except CycleError:
98 errors.append(
99 f"I1 violation: adding {frm!r} → {to!r} ({rel['edge_type']}) "
100 f"would introduce a cycle"
101 )
102 i1_bad.add((frm, to, rel["edge_type"]))
103
104 # ── I2 — Root distance (musehub#226: grace period, then hard error) ────
105 idx = RootDistanceIndex.build(dag)
106 for rec in identities:
107 h = rec["handle"]
108 if idx.distance(h) is None:
109 age = now - _parse_registered_at(rec["registered_at"])
110 if age > timedelta(days=GRACE_PERIOD_DAYS):
111 errors.append(
112 f"I2 violation: {h!r} ({rec['type']}) has had no path to any "
113 f"human root for {age.days} day(s) — exceeds the "
114 f"{GRACE_PERIOD_DAYS}-day grace period"
115 )
116 else:
117 warnings.append(
118 f"I2 warning: {h!r} ({rec['type']}) has no path to any human "
119 f"root — orphaned for {age.days} day(s), "
120 f"{GRACE_PERIOD_DAYS - age.days} remaining before this becomes "
121 f"a hard error"
122 )
123
124 # ── I3 — Authorization ────────────────────────────────────────────────
125 # member_of relationships are processed in input order, maintaining a
126 # running "already-committed members" set per org. This mirrors the
127 # append-only event log: each relationship sees the members who were
128 # committed before it, not those added by later relationships in the
129 # same push. This naturally solves the bootstrap problem: the first
130 # member of an empty org self-authorizes; subsequent members need
131 # min(quorum, |prior|) existing signatures.
132 committed_members: dict[str, set[str]] = {} # org → members validated so far
133
134 for rel in relationships:
135 if (rel["from_handle"], rel["to_handle"], rel["edge_type"]) in i1_bad:
136 continue
137
138 signers = {s["signer"] for s in rel.get("authorized_by", [])}
139 frm = rel["from_handle"]
140 to = rel["to_handle"]
141 edge = rel["edge_type"]
142
143 if edge == "spawns":
144 if frm not in signers:
145 errors.append(
146 f"I3 violation: spawns({frm!r} → {to!r}) must be authorized "
147 f"by {frm!r} — found signers: {sorted(signers)}"
148 )
149
150 elif edge == "member_of":
151 org_h = to
152 quorum = _quorum_map.get(org_h, 1)
153 prior = committed_members.get(org_h, set())
154
155 if not prior:
156 # Founding member — only self-authorization required
157 if frm not in signers:
158 errors.append(
159 f"I3 violation: member_of({frm!r} → {org_h!r}) "
160 f"bootstrap join must be self-authorized — no signature from {frm!r}"
161 )
162 else:
163 # Need min(quorum, |prior|) existing member signatures
164 needed = min(quorum, len(prior))
165 authorizing = signers & prior
166 if len(authorizing) < needed:
167 errors.append(
168 f"I3 violation: member_of({frm!r} → {org_h!r}) requires "
169 f"{needed} member signature(s) (quorum={quorum}, prior={len(prior)}), "
170 f"got {len(authorizing)} from {sorted(prior)}"
171 )
172
173 # Advance the running member set regardless of auth outcome so
174 # subsequent relationships see the correct prior state.
175 committed_members.setdefault(org_h, set()).add(frm)
176
177 return ValidationResult(
178 valid=len(errors) == 0,
179 errors=errors,
180 warnings=warnings,
181 )
File History 1 commit
sha256:066f0f58448cc0553bff3c845cbb8e1f7a6bdecf53fce6bc5c2ff5e0ddad3d71 feat: wire IdentityPushValidator into the real push pipelin… Sonnet 5 minor ⚠ 2 days ago