gabriel / musehub public

test_identity_push_validator.py file-level

at sha256:8 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:1 fix: getting-started page's muse --version sample output showed musehub… · gabriel · Oct 23, 2026
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 datetime import datetime, timedelta, timezone
23 from decimal import Decimal
24
25 import pytest
26 from musehub.types.json_types import JSONObject
27
28 from musehub.graph.push_validator import (
29 IdentityPushValidator,
30 ValidationResult,
31 )
32
33
34 def _dt(*args: int) -> datetime:
35 return datetime(*args, tzinfo=timezone.utc)
36
37
38 # ── record factories ──────────────────────────────────────────────────────────
39
40 def human(handle: str) -> JSONObject:
41 return dict(handle=handle, type="human", pubkey="ed25519:AAAA",
42 quorum=None, registered_at="2026-04-21T00:00:00Z", metadata={})
43
44
45 def agent(handle: str) -> JSONObject:
46 return dict(handle=handle, type="agent", pubkey="ed25519:BBBB",
47 quorum=None, registered_at="2026-04-21T00:00:00Z", metadata={})
48
49
50 def human_registered(handle: str, registered_at: datetime) -> JSONObject:
51 return dict(handle=handle, type="human", pubkey="ed25519:AAAA",
52 quorum=None, registered_at=registered_at.isoformat(), metadata={})
53
54
55 def agent_registered(handle: str, registered_at: datetime) -> JSONObject:
56 return dict(handle=handle, type="agent", pubkey="ed25519:BBBB",
57 quorum=None, registered_at=registered_at.isoformat(), metadata={})
58
59
60 def org(handle: str, quorum: int = 1) -> JSONObject:
61 return dict(handle=handle, type="org", pubkey=None,
62 quorum=quorum, registered_at="2026-04-21T00:00:00Z", metadata={})
63
64
65 def spawns(frm: str, to: str, *signers: str) -> JSONObject:
66 return dict(
67 from_handle=frm, to_handle=to, edge_type="spawns",
68 weight=None,
69 authorized_by=[dict(signer=s, signature="ed25519:SIG", signed_at="2026-04-21T00:00:00Z")
70 for s in signers],
71 )
72
73
74 def member_of(member: str, org_handle: str, weight: str = "1", *signers: str) -> JSONObject:
75 return dict(
76 from_handle=member, to_handle=org_handle, edge_type="member_of",
77 weight=weight,
78 authorized_by=[dict(signer=s, signature="ed25519:SIG", signed_at="2026-04-21T00:00:00Z")
79 for s in signers],
80 )
81
82
83 @pytest.fixture
84 def v() -> IdentityPushValidator:
85 return IdentityPushValidator()
86
87
88 # ── empty / trivial ───────────────────────────────────────────────────────────
89
90 class TestTrivial:
91 def test_empty_graph_is_valid(self, v: IdentityPushValidator) -> None:
92 result = v.validate([], [])
93 assert result.valid is True
94 assert result.errors == []
95
96 def test_single_human_is_valid(self, v: IdentityPushValidator) -> None:
97 result = v.validate([human("gabriel")], [])
98 assert result.valid is True
99
100 def test_single_org_no_members_warning(self, v: IdentityPushValidator) -> None:
101 # now == registered_at β€” freshly orphaned, within the I2 grace period.
102 result = v.validate([org("acme")], [], now=_dt(2026, 4, 21))
103 # orphaned org β€” no path to a human root
104 assert result.valid is True # warning, not error
105 assert any("acme" in w for w in result.warnings)
106
107 def test_orphaned_agent_warning(self, v: IdentityPushValidator) -> None:
108 result = v.validate([agent("bot")], [], now=_dt(2026, 4, 21))
109 assert result.valid is True
110 assert any("bot" in w for w in result.warnings)
111
112
113 # ── I1 Acyclicity ─────────────────────────────────────────────────────────────
114
115 class TestI1Acyclicity:
116 def test_linear_chain_valid(self, v: IdentityPushValidator) -> None:
117 identities = [human("h"), agent("a1"), agent("a2")]
118 rels = [spawns("h", "a1", "h"), spawns("a1", "a2", "a1")]
119 assert v.validate(identities, rels).valid is True
120
121 def test_self_loop_rejected(self, v: IdentityPushValidator) -> None:
122 identities = [agent("bot")]
123 rels = [spawns("bot", "bot", "bot")]
124 result = v.validate(identities, rels)
125 assert result.valid is False
126 assert any("cycle" in e.lower() or "I1" in e for e in result.errors)
127
128 def test_direct_cycle_rejected(self, v: IdentityPushValidator) -> None:
129 identities = [human("alice"), agent("bot")]
130 rels = [spawns("alice", "bot", "alice"), spawns("bot", "alice", "alice")]
131 result = v.validate(identities, rels)
132 assert result.valid is False
133 assert result.errors
134
135 def test_indirect_cycle_three_nodes_rejected(self, v: IdentityPushValidator) -> None:
136 identities = [agent("a"), agent("b"), agent("c")]
137 rels = [spawns("a", "b", "a"), spawns("b", "c", "b"), spawns("c", "a", "c")]
138 result = v.validate(identities, rels)
139 assert result.valid is False
140
141 def test_diamond_dag_valid(self, v: IdentityPushValidator) -> None:
142 # alice→a1, alice→a2, a1→target, a2→target — valid DAG, no cycle
143 identities = [human("alice"), agent("a1"), agent("a2"), agent("target")]
144 rels = [
145 spawns("alice", "a1", "alice"),
146 spawns("alice", "a2", "alice"),
147 spawns("a1", "target", "a1"),
148 spawns("a2", "target", "a2"),
149 ]
150 assert v.validate(identities, rels).valid is True
151
152 def test_member_of_cycle_rejected(self, v: IdentityPushValidator) -> None:
153 identities = [org("org-a"), org("org-b")]
154 rels = [member_of("org-a", "org-b", "1"), member_of("org-b", "org-a", "1")]
155 result = v.validate(identities, rels)
156 assert result.valid is False
157
158 def test_cross_edge_type_cycle_rejected(self, v: IdentityPushValidator) -> None:
159 # alice --spawns--> bot --member_of--> alice (cross-type cycle)
160 identities = [human("alice"), agent("bot")]
161 rels = [spawns("alice", "bot", "alice"), member_of("bot", "alice", "1")]
162 result = v.validate(identities, rels)
163 assert result.valid is False
164
165
166 # ── I2 Root distance ──────────────────────────────────────────────────────────
167
168 class TestI2RootDistance:
169 def test_human_no_warning(self, v: IdentityPushValidator) -> None:
170 result = v.validate([human("gabriel")], [])
171 assert result.warnings == []
172
173 def test_agent_spawned_by_human_no_warning(self, v: IdentityPushValidator) -> None:
174 identities = [human("gabriel"), agent("bot")]
175 rels = [spawns("gabriel", "bot", "gabriel")]
176 result = v.validate(identities, rels)
177 assert not any("bot" in w for w in result.warnings)
178
179 def test_agent_chain_no_warning(self, v: IdentityPushValidator) -> None:
180 identities = [human("h"), agent("a1"), agent("a2"), agent("a3")]
181 rels = [
182 spawns("h", "a1", "h"),
183 spawns("a1", "a2", "h"),
184 spawns("a2", "a3", "h"),
185 ]
186 assert v.validate(identities, rels).warnings == []
187
188 def test_orphaned_agent_warned(self, v: IdentityPushValidator) -> None:
189 result = v.validate([agent("bot")], [], now=_dt(2026, 4, 21))
190 assert any("bot" in w for w in result.warnings)
191
192 def test_org_with_human_member_no_warning(self, v: IdentityPushValidator) -> None:
193 identities = [human("alice"), org("acme")]
194 rels = [member_of("alice", "acme", "1", "alice")]
195 assert not any("acme" in w for w in v.validate(identities, rels).warnings)
196
197 def test_org_with_no_human_path_warned(self, v: IdentityPushValidator) -> None:
198 # org exists but no members at all
199 result = v.validate([org("acme")], [], now=_dt(2026, 4, 21))
200 assert any("acme" in w for w in result.warnings)
201
202 def test_nested_org_with_human_root_no_warning(self, v: IdentityPushValidator) -> None:
203 identities = [human("alice"), org("sub"), org("parent")]
204 rels = [
205 member_of("alice", "sub", "1", "alice"),
206 member_of("sub", "parent", "1", "alice"),
207 ]
208 result = v.validate(identities, rels)
209 assert not any("parent" in w for w in result.warnings)
210
211
212 # ── I2 Root distance β€” grace-period hard error (musehub#226) ──────────────────
213 #
214 # Decision: orphaned nodes (no path to any human root) stay a soft warning for
215 # a 30-day grace period (covers mid-construction identities and bulk-import-
216 # then-wire-later workflows), then become a hard error β€” the push is rejected
217 # β€” once that grace period has elapsed with the node still orphaned.
218
219 class TestI2GracePeriodHardError:
220 def test_freshly_orphaned_node_is_warning_not_error(
221 self, v: IdentityPushValidator
222 ) -> None:
223 """A node registered 'now' with no path to a human root is still
224 within the grace period β€” warning only, push still accepted."""
225 now = _dt(2026, 6, 1)
226 identities = [agent_registered("bot", now)]
227 result = v.validate(identities, [], now=now)
228 assert any("bot" in w for w in result.warnings)
229 assert not any("bot" in e for e in result.errors)
230 assert result.valid is True
231
232 def test_orphaned_node_within_grace_period_is_still_warning(
233 self, v: IdentityPushValidator
234 ) -> None:
235 """29 days old β€” still inside the 30-day grace period."""
236 now = _dt(2026, 6, 1)
237 registered = now - timedelta(days=29)
238 identities = [agent_registered("bot", registered)]
239 result = v.validate(identities, [], now=now)
240 assert any("bot" in w for w in result.warnings)
241 assert not any("bot" in e for e in result.errors)
242 assert result.valid is True
243
244 def test_orphaned_node_past_grace_period_is_hard_error(
245 self, v: IdentityPushValidator
246 ) -> None:
247 """31 days old β€” past the 30-day grace period: push must be rejected."""
248 now = _dt(2026, 6, 1)
249 registered = now - timedelta(days=31)
250 identities = [agent_registered("bot", registered)]
251 result = v.validate(identities, [], now=now)
252 assert any("bot" in e for e in result.errors), (
253 f"expected an I2 hard-error mentioning 'bot', got errors={result.errors!r}"
254 )
255 assert result.valid is False
256
257 def test_old_but_connected_node_has_no_warning_or_error(
258 self, v: IdentityPushValidator
259 ) -> None:
260 """A node registered long ago but WITH a path to a human root must
261 never warn or error, regardless of its age."""
262 now = _dt(2026, 6, 1)
263 old = now - timedelta(days=365)
264 identities = [human_registered("gabriel", old), agent_registered("bot", old)]
265 rels = [spawns("gabriel", "bot", "gabriel")]
266 result = v.validate(identities, rels, now=now)
267 assert not any("bot" in w for w in result.warnings)
268 assert not any("bot" in e for e in result.errors)
269 assert result.valid is True
270
271 def test_default_now_is_real_clock_when_omitted(
272 self, v: IdentityPushValidator
273 ) -> None:
274 """Omitting now= must not crash β€” defaults to the real wall clock."""
275 identities = [agent("bot")] # registered_at is a fixed past date in the fixture
276 result = v.validate(identities, [])
277 # registered_at fixture date is long in the past relative to "real now" β€”
278 # this must be a hard error by default, not silently ignored.
279 assert any("bot" in e for e in result.errors)
280
281
282 # ── I3 Authorization β€” spawns ─────────────────────────────────────────────────
283
284 class TestI3AuthSpawns:
285 def test_spawner_signature_present_valid(self, v: IdentityPushValidator) -> None:
286 identities = [human("gabriel"), agent("bot")]
287 rels = [spawns("gabriel", "bot", "gabriel")] # gabriel signed
288 assert v.validate(identities, rels).valid is True
289
290 def test_spawner_signature_absent_rejected(self, v: IdentityPushValidator) -> None:
291 identities = [human("gabriel"), agent("bot")]
292 rels = [spawns("gabriel", "bot")] # no signatures
293 result = v.validate(identities, rels)
294 assert result.valid is False
295 assert any("authorized" in e.lower() or "signature" in e.lower() or "I3" in e
296 for e in result.errors)
297
298 def test_wrong_signer_rejected(self, v: IdentityPushValidator) -> None:
299 # alice tries to authorize gabriel's spawn β€” she has no right
300 identities = [human("gabriel"), human("alice"), agent("bot")]
301 rels = [spawns("gabriel", "bot", "alice")]
302 result = v.validate(identities, rels)
303 assert result.valid is False
304
305 def test_extra_signers_ok_as_long_as_spawner_present(self, v: IdentityPushValidator) -> None:
306 identities = [human("gabriel"), human("alice"), agent("bot")]
307 rels = [spawns("gabriel", "bot", "gabriel", "alice")] # gabriel + alice
308 assert v.validate(identities, rels).valid is True
309
310 def test_agent_spawning_agent_authorized_by_original_spawner(self, v: IdentityPushValidator) -> None:
311 # gabriel β†’ bot-1 β†’ bot-2; bot-1 must sign the bot-2 spawn
312 identities = [human("gabriel"), agent("bot-1"), agent("bot-2")]
313 rels = [
314 spawns("gabriel", "bot-1", "gabriel"),
315 spawns("bot-1", "bot-2", "bot-1"),
316 ]
317 assert v.validate(identities, rels).valid is True
318
319 def test_agent_spawn_wrong_signer_rejected(self, v: IdentityPushValidator) -> None:
320 identities = [human("gabriel"), agent("bot-1"), agent("bot-2")]
321 rels = [
322 spawns("gabriel", "bot-1", "gabriel"),
323 spawns("bot-1", "bot-2", "gabriel"), # gabriel signs bot-1's spawn β€” wrong
324 ]
325 result = v.validate(identities, rels)
326 assert result.valid is False
327
328
329 # ── I3 Authorization β€” member_of ─────────────────────────────────────────────
330
331 class TestI3AuthMemberOf:
332 def test_first_member_self_authorized_valid(self, v: IdentityPushValidator) -> None:
333 # First member of an org β€” no prior members β€” authorized by themselves
334 identities = [human("alice"), org("acme", quorum=1)]
335 rels = [member_of("alice", "acme", "1", "alice")]
336 assert v.validate(identities, rels).valid is True
337
338 def test_membership_requires_quorum_of_current_members(self, v: IdentityPushValidator) -> None:
339 # acme has quorum=2; alice and bob are members
340 # carol wants to join β€” needs 2 current member signatures
341 identities = [human("alice"), human("bob"), human("carol"), org("acme", quorum=2)]
342 rels = [
343 member_of("alice", "acme", "1", "alice"),
344 member_of("bob", "acme", "1", "alice"), # alice authorized bob
345 member_of("carol", "acme", "1", "alice", "bob"), # alice + bob β†’ quorum=2 βœ“
346 ]
347 assert v.validate(identities, rels).valid is True
348
349 def test_membership_insufficient_signatures_rejected(self, v: IdentityPushValidator) -> None:
350 identities = [human("alice"), human("bob"), human("carol"), org("acme", quorum=2)]
351 rels = [
352 member_of("alice", "acme", "1", "alice"),
353 member_of("bob", "acme", "1", "alice"),
354 member_of("carol", "acme", "1", "alice"), # only alice signed β€” quorum=2 not met
355 ]
356 result = v.validate(identities, rels)
357 assert result.valid is False
358
359 def test_non_member_signature_does_not_count(self, v: IdentityPushValidator) -> None:
360 # dave is not a member of acme β€” his signature shouldn't satisfy quorum
361 identities = [human("alice"), human("dave"), org("acme", quorum=1)]
362 rels = [
363 member_of("alice", "acme", "1", "alice"),
364 member_of("dave", "acme", "1", "dave"), # dave signs his own join β€” he's not yet a member
365 ]
366 # dave's self-authorization shouldn't count unless acme quorum=1 allows first-member join
367 # Since alice is already a member, dave needs alice's signature
368 result = v.validate(identities, rels)
369 assert result.valid is False
370
371 def test_no_signatures_rejected(self, v: IdentityPushValidator) -> None:
372 identities = [human("alice"), org("acme", quorum=1)]
373 rels = [member_of("alice", "acme", "1")] # no signatures
374 result = v.validate(identities, rels)
375 assert result.valid is False
376
377 def test_quorum_1_founder_joins_empty_org_valid(self, v: IdentityPushValidator) -> None:
378 # The very first member of a quorum=1 org β€” self-authorization is sufficient
379 identities = [human("gabriel"), org("musehub-org", quorum=1)]
380 rels = [member_of("gabriel", "musehub-org", "1", "gabriel")]
381 assert v.validate(identities, rels).valid is True
382
383
384 # ── Combined scenarios ────────────────────────────────────────────────────────
385
386 class TestCombinedScenarios:
387 def test_full_graph_gabriel_claudecode_musehub(self, v: IdentityPushValidator) -> None:
388 """The demo scenario from the experiment: all valid.
389
390 Order matters for I3 ordered processing:
391 1. gabriel joins graph-lab first (founder, self-signs)
392 2. claude-code joins (1 prior = gabriel, min(2,1)=1 β†’ gabriel signs)
393 3. musehub-org joins (2 prior = {gabriel, claude-code}, min(2,2)=2 β†’ both sign)
394 """
395 identities = [
396 human("gabriel"),
397 agent("claude-code"),
398 org("musehub-org", quorum=1),
399 org("graph-lab", quorum=2),
400 ]
401 rels = [
402 spawns("gabriel", "claude-code", "gabriel"),
403 member_of("gabriel", "musehub-org", "1", "gabriel"),
404 member_of("gabriel", "graph-lab", "1", "gabriel"),
405 member_of("claude-code", "graph-lab", "1", "gabriel"),
406 member_of("musehub-org", "graph-lab", "1", "gabriel", "claude-code"),
407 ]
408 result = v.validate(identities, rels)
409 assert result.valid is True
410 assert result.errors == []
411
412 def test_i1_error_does_not_suppress_i3_errors(self, v: IdentityPushValidator) -> None:
413 # Both a cycle AND a missing signature β€” both errors reported
414 identities = [human("alice"), agent("bot")]
415 rels = [
416 spawns("alice", "bot"), # I3: no signature
417 spawns("bot", "alice", "bot"), # I1: cycle
418 ]
419 result = v.validate(identities, rels)
420 assert result.valid is False
421 assert len(result.errors) >= 2
422
423 def test_warnings_do_not_make_result_invalid(self, v: IdentityPushValidator) -> None:
424 # Valid graph + orphaned agent β†’ valid but with warning
425 identities = [human("gabriel"), agent("orphan")]
426 rels = [spawns("gabriel", "gabriel")] # no spawn of orphan
427 # orphan has no spawner β†’ warning (within I2 grace period)
428 result = v.validate([human("gabriel"), agent("orphan")], [], now=_dt(2026, 4, 21))
429 assert result.valid is True
430 assert result.warnings # at least one warning about orphan