gabriel / musehub public
test_identity_push_validator.py python
343 lines 14.8 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 156 days ago
1 """TDD — IdentityPushValidator.
2
3 Hub-side enforcement layer for identity-domain pushes.
4 Called by wire_push before any objects are persisted.
5 Enforces all three invariants against the full committed state.
6
7 Invariants
8 ----------
9 I1 Acyclicity — hard error → push rejected
10 I2 Root distance — warning → push accepted, node annotated as orphaned
11 I3 Authorization — hard error → push rejected
12
13 Authorization rules
14 -------------------
15 spawns(from, to) → from_handle must appear in authorized_by
16 member_of(member, org) → quorum-many CURRENT members of org must appear in authorized_by
17 "current member" = another identity with a member_of edge to the same org
18 The org's quorum threshold comes from its IdentityRecord.quorum field.
19 """
20 from __future__ import annotations
21
22 from decimal import Decimal
23
24 import pytest
25
26 from musehub.graph.push_validator import (
27 IdentityPushValidator,
28 ValidationResult,
29 )
30
31
32 # ── record factories ──────────────────────────────────────────────────────────
33
34 def human(handle: str) -> dict:
35 return dict(handle=handle, type="human", pubkey="ed25519:AAAA",
36 quorum=None, registered_at="2026-04-21T00:00:00Z", metadata={})
37
38
39 def agent(handle: str) -> dict:
40 return dict(handle=handle, type="agent", pubkey="ed25519:BBBB",
41 quorum=None, registered_at="2026-04-21T00:00:00Z", metadata={})
42
43
44 def org(handle: str, quorum: int = 1) -> dict:
45 return dict(handle=handle, type="org", pubkey=None,
46 quorum=quorum, registered_at="2026-04-21T00:00:00Z", metadata={})
47
48
49 def spawns(frm: str, to: str, *signers: str) -> dict:
50 return dict(
51 from_handle=frm, to_handle=to, edge_type="spawns",
52 weight=None,
53 authorized_by=[dict(signer=s, signature="ed25519:SIG", signed_at="2026-04-21T00:00:00Z")
54 for s in signers],
55 )
56
57
58 def member_of(member: str, org_handle: str, weight: str = "1", *signers: str) -> dict:
59 return dict(
60 from_handle=member, to_handle=org_handle, edge_type="member_of",
61 weight=weight,
62 authorized_by=[dict(signer=s, signature="ed25519:SIG", signed_at="2026-04-21T00:00:00Z")
63 for s in signers],
64 )
65
66
67 @pytest.fixture
68 def v() -> IdentityPushValidator:
69 return IdentityPushValidator()
70
71
72 # ── empty / trivial ───────────────────────────────────────────────────────────
73
74 class TestTrivial:
75 def test_empty_graph_is_valid(self, v):
76 result = v.validate([], [])
77 assert result.valid is True
78 assert result.errors == []
79
80 def test_single_human_is_valid(self, v):
81 result = v.validate([human("gabriel")], [])
82 assert result.valid is True
83
84 def test_single_org_no_members_warning(self, v):
85 result = v.validate([org("acme")], [])
86 # orphaned org — no path to a human root
87 assert result.valid is True # warning, not error
88 assert any("acme" in w for w in result.warnings)
89
90 def test_orphaned_agent_warning(self, v):
91 result = v.validate([agent("bot")], [])
92 assert result.valid is True
93 assert any("bot" in w for w in result.warnings)
94
95
96 # ── I1 Acyclicity ─────────────────────────────────────────────────────────────
97
98 class TestI1Acyclicity:
99 def test_linear_chain_valid(self, v):
100 identities = [human("h"), agent("a1"), agent("a2")]
101 rels = [spawns("h", "a1", "h"), spawns("a1", "a2", "a1")]
102 assert v.validate(identities, rels).valid is True
103
104 def test_self_loop_rejected(self, v):
105 identities = [agent("bot")]
106 rels = [spawns("bot", "bot", "bot")]
107 result = v.validate(identities, rels)
108 assert result.valid is False
109 assert any("cycle" in e.lower() or "I1" in e for e in result.errors)
110
111 def test_direct_cycle_rejected(self, v):
112 identities = [human("alice"), agent("bot")]
113 rels = [spawns("alice", "bot", "alice"), spawns("bot", "alice", "alice")]
114 result = v.validate(identities, rels)
115 assert result.valid is False
116 assert result.errors
117
118 def test_indirect_cycle_three_nodes_rejected(self, v):
119 identities = [agent("a"), agent("b"), agent("c")]
120 rels = [spawns("a", "b", "a"), spawns("b", "c", "b"), spawns("c", "a", "c")]
121 result = v.validate(identities, rels)
122 assert result.valid is False
123
124 def test_diamond_dag_valid(self, v):
125 # alice→a1, alice→a2, a1→target, a2→target — valid DAG, no cycle
126 identities = [human("alice"), agent("a1"), agent("a2"), agent("target")]
127 rels = [
128 spawns("alice", "a1", "alice"),
129 spawns("alice", "a2", "alice"),
130 spawns("a1", "target", "a1"),
131 spawns("a2", "target", "a2"),
132 ]
133 assert v.validate(identities, rels).valid is True
134
135 def test_member_of_cycle_rejected(self, v):
136 identities = [org("org-a"), org("org-b")]
137 rels = [member_of("org-a", "org-b", "1"), member_of("org-b", "org-a", "1")]
138 result = v.validate(identities, rels)
139 assert result.valid is False
140
141 def test_cross_edge_type_cycle_rejected(self, v):
142 # alice --spawns--> bot --member_of--> alice (cross-type cycle)
143 identities = [human("alice"), agent("bot")]
144 rels = [spawns("alice", "bot", "alice"), member_of("bot", "alice", "1")]
145 result = v.validate(identities, rels)
146 assert result.valid is False
147
148
149 # ── I2 Root distance ──────────────────────────────────────────────────────────
150
151 class TestI2RootDistance:
152 def test_human_no_warning(self, v):
153 result = v.validate([human("gabriel")], [])
154 assert result.warnings == []
155
156 def test_agent_spawned_by_human_no_warning(self, v):
157 identities = [human("gabriel"), agent("bot")]
158 rels = [spawns("gabriel", "bot", "gabriel")]
159 result = v.validate(identities, rels)
160 assert not any("bot" in w for w in result.warnings)
161
162 def test_agent_chain_no_warning(self, v):
163 identities = [human("h"), agent("a1"), agent("a2"), agent("a3")]
164 rels = [
165 spawns("h", "a1", "h"),
166 spawns("a1", "a2", "h"),
167 spawns("a2", "a3", "h"),
168 ]
169 assert v.validate(identities, rels).warnings == []
170
171 def test_orphaned_agent_warned(self, v):
172 result = v.validate([agent("bot")], [])
173 assert any("bot" in w for w in result.warnings)
174
175 def test_org_with_human_member_no_warning(self, v):
176 identities = [human("alice"), org("acme")]
177 rels = [member_of("alice", "acme", "1", "alice")]
178 assert not any("acme" in w for w in v.validate(identities, rels).warnings)
179
180 def test_org_with_no_human_path_warned(self, v):
181 # org exists but no members at all
182 result = v.validate([org("acme")], [])
183 assert any("acme" in w for w in result.warnings)
184
185 def test_nested_org_with_human_root_no_warning(self, v):
186 identities = [human("alice"), org("sub"), org("parent")]
187 rels = [
188 member_of("alice", "sub", "1", "alice"),
189 member_of("sub", "parent", "1", "alice"),
190 ]
191 result = v.validate(identities, rels)
192 assert not any("parent" in w for w in result.warnings)
193
194
195 # ── I3 Authorization — spawns ─────────────────────────────────────────────────
196
197 class TestI3AuthSpawns:
198 def test_spawner_signature_present_valid(self, v):
199 identities = [human("gabriel"), agent("bot")]
200 rels = [spawns("gabriel", "bot", "gabriel")] # gabriel signed
201 assert v.validate(identities, rels).valid is True
202
203 def test_spawner_signature_absent_rejected(self, v):
204 identities = [human("gabriel"), agent("bot")]
205 rels = [spawns("gabriel", "bot")] # no signatures
206 result = v.validate(identities, rels)
207 assert result.valid is False
208 assert any("authorized" in e.lower() or "signature" in e.lower() or "I3" in e
209 for e in result.errors)
210
211 def test_wrong_signer_rejected(self, v):
212 # alice tries to authorize gabriel's spawn — she has no right
213 identities = [human("gabriel"), human("alice"), agent("bot")]
214 rels = [spawns("gabriel", "bot", "alice")]
215 result = v.validate(identities, rels)
216 assert result.valid is False
217
218 def test_extra_signers_ok_as_long_as_spawner_present(self, v):
219 identities = [human("gabriel"), human("alice"), agent("bot")]
220 rels = [spawns("gabriel", "bot", "gabriel", "alice")] # gabriel + alice
221 assert v.validate(identities, rels).valid is True
222
223 def test_agent_spawning_agent_authorized_by_original_spawner(self, v):
224 # gabriel → bot-1 → bot-2; bot-1 must sign the bot-2 spawn
225 identities = [human("gabriel"), agent("bot-1"), agent("bot-2")]
226 rels = [
227 spawns("gabriel", "bot-1", "gabriel"),
228 spawns("bot-1", "bot-2", "bot-1"),
229 ]
230 assert v.validate(identities, rels).valid is True
231
232 def test_agent_spawn_wrong_signer_rejected(self, v):
233 identities = [human("gabriel"), agent("bot-1"), agent("bot-2")]
234 rels = [
235 spawns("gabriel", "bot-1", "gabriel"),
236 spawns("bot-1", "bot-2", "gabriel"), # gabriel signs bot-1's spawn — wrong
237 ]
238 result = v.validate(identities, rels)
239 assert result.valid is False
240
241
242 # ── I3 Authorization — member_of ─────────────────────────────────────────────
243
244 class TestI3AuthMemberOf:
245 def test_first_member_self_authorized_valid(self, v):
246 # First member of an org — no prior members — authorized by themselves
247 identities = [human("alice"), org("acme", quorum=1)]
248 rels = [member_of("alice", "acme", "1", "alice")]
249 assert v.validate(identities, rels).valid is True
250
251 def test_membership_requires_quorum_of_current_members(self, v):
252 # acme has quorum=2; alice and bob are members
253 # carol wants to join — needs 2 current member signatures
254 identities = [human("alice"), human("bob"), human("carol"), org("acme", quorum=2)]
255 rels = [
256 member_of("alice", "acme", "1", "alice"),
257 member_of("bob", "acme", "1", "alice"), # alice authorized bob
258 member_of("carol", "acme", "1", "alice", "bob"), # alice + bob → quorum=2 ✓
259 ]
260 assert v.validate(identities, rels).valid is True
261
262 def test_membership_insufficient_signatures_rejected(self, v):
263 identities = [human("alice"), human("bob"), human("carol"), org("acme", quorum=2)]
264 rels = [
265 member_of("alice", "acme", "1", "alice"),
266 member_of("bob", "acme", "1", "alice"),
267 member_of("carol", "acme", "1", "alice"), # only alice signed — quorum=2 not met
268 ]
269 result = v.validate(identities, rels)
270 assert result.valid is False
271
272 def test_non_member_signature_does_not_count(self, v):
273 # dave is not a member of acme — his signature shouldn't satisfy quorum
274 identities = [human("alice"), human("dave"), org("acme", quorum=1)]
275 rels = [
276 member_of("alice", "acme", "1", "alice"),
277 member_of("dave", "acme", "1", "dave"), # dave signs his own join — he's not yet a member
278 ]
279 # dave's self-authorization shouldn't count unless acme quorum=1 allows first-member join
280 # Since alice is already a member, dave needs alice's signature
281 result = v.validate(identities, rels)
282 assert result.valid is False
283
284 def test_no_signatures_rejected(self, v):
285 identities = [human("alice"), org("acme", quorum=1)]
286 rels = [member_of("alice", "acme", "1")] # no signatures
287 result = v.validate(identities, rels)
288 assert result.valid is False
289
290 def test_quorum_1_founder_joins_empty_org_valid(self, v):
291 # The very first member of a quorum=1 org — self-authorization is sufficient
292 identities = [human("gabriel"), org("musehub-org", quorum=1)]
293 rels = [member_of("gabriel", "musehub-org", "1", "gabriel")]
294 assert v.validate(identities, rels).valid is True
295
296
297 # ── Combined scenarios ────────────────────────────────────────────────────────
298
299 class TestCombinedScenarios:
300 def test_full_graph_gabriel_claudecode_musehub(self, v):
301 """The demo scenario from the experiment: all valid.
302
303 Order matters for I3 ordered processing:
304 1. gabriel joins graph-lab first (founder, self-signs)
305 2. claude-code joins (1 prior = gabriel, min(2,1)=1 → gabriel signs)
306 3. musehub-org joins (2 prior = {gabriel, claude-code}, min(2,2)=2 → both sign)
307 """
308 identities = [
309 human("gabriel"),
310 agent("claude-code"),
311 org("musehub-org", quorum=1),
312 org("graph-lab", quorum=2),
313 ]
314 rels = [
315 spawns("gabriel", "claude-code", "gabriel"),
316 member_of("gabriel", "musehub-org", "1", "gabriel"),
317 member_of("gabriel", "graph-lab", "1", "gabriel"),
318 member_of("claude-code", "graph-lab", "1", "gabriel"),
319 member_of("musehub-org", "graph-lab", "1", "gabriel", "claude-code"),
320 ]
321 result = v.validate(identities, rels)
322 assert result.valid is True
323 assert result.errors == []
324
325 def test_i1_error_does_not_suppress_i3_errors(self, v):
326 # Both a cycle AND a missing signature — both errors reported
327 identities = [human("alice"), agent("bot")]
328 rels = [
329 spawns("alice", "bot"), # I3: no signature
330 spawns("bot", "alice", "bot"), # I1: cycle
331 ]
332 result = v.validate(identities, rels)
333 assert result.valid is False
334 assert len(result.errors) >= 2
335
336 def test_warnings_do_not_make_result_invalid(self, v):
337 # Valid graph + orphaned agent → valid but with warning
338 identities = [human("gabriel"), agent("orphan")]
339 rels = [spawns("gabriel", "gabriel")] # no spawn of orphan
340 # orphan has no spawner → warning
341 result = v.validate([human("gabriel"), agent("orphan")], [])
342 assert result.valid is True
343 assert result.warnings # at least one warning about orphan
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 156 days ago