push_validator.py
python
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