gabriel / musehub public
musehub_governance.py python
205 lines 6.6 KB
Raw
sha256:92528ae07d0e1239d87fd5fd1f439e8fbb49c9778a9a400bc4a736073fb28316 feat: byte-range blob reads, file attribution DAG walk, bra… Sonnet 4.6 minor ⚠ breaking 43 days ago
1 """musehub.services.musehub_governance — quorum enforcement for governed repos.
2
3 A repo is "governed" when its HEAD snapshot contains a ``governance.json``
4 file. The file declares a threshold and a list of member fingerprints; a
5 proposal merge is blocked until ≥ threshold members have posted an approved
6 review.
7
8 Public API
9 ----------
10 load_governance(session, repo_id) -> dict | None
11 Read and parse governance.json from the repo's HEAD. Returns None when
12 the file is absent.
13
14 check_quorum(session, repo_id, proposal_id, governance) -> (met, found, threshold)
15 Count approved reviews from quorum members. Returns a 3-tuple:
16 (threshold_met: bool, member_approvals_found: int, threshold: int).
17 """
18
19 import json
20 import logging
21
22 from sqlalchemy import select
23 from sqlalchemy.ext.asyncio import AsyncSession
24
25 from musehub.db import musehub_auth_models as auth_db
26 from musehub.db.musehub_identity_models import MusehubIdentity
27 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubObject, MusehubRepo, MusehubSnapshot
28 from musehub.db.musehub_social_models import MusehubProposalReview
29 from musehub.storage.backends import read_object_bytes
30 from musehub.types.json_types import JSONObject
31
32 logger = logging.getLogger(__name__)
33
34 async def load_governance(session: AsyncSession, repo_id: str) -> JSONObject | None:
35 """Return parsed governance.json from the repo HEAD, or None if absent."""
36 from musehub.services.musehub_repository import get_file_at_ref
37
38 file_meta = await get_file_at_ref(session, repo_id, "main", "governance.json")
39 if file_meta is None:
40 return None
41
42 object_id = file_meta["object_id"]
43 obj = await session.get(MusehubObject, object_id)
44 if obj is None:
45 return None
46
47 content = await read_object_bytes(obj, session=session)
48 if not content:
49 return None
50
51 try:
52 return json.loads(content)
53 except Exception:
54 logger.warning("governance.json in repo %s is not valid JSON", repo_id)
55 return None
56
57 async def resolve_handle_to_fingerprint(
58 session: AsyncSession,
59 handle: str,
60 ) -> str | None:
61 """Return the fingerprint for ``handle`` by reading their identity repo HEAD.
62
63 Reads ``identities/{handle}.json`` from the identity repo's HEAD snapshot,
64 decodes the ``pubkey`` field (``"ed25519:<base64url>"``), and returns the
65 canonical ``sha256:<hex>`` fingerprint. Returns ``None`` when the identity
66 repo does not exist or contains no pubkey.
67 """
68 import base64
69 import json as _json
70
71 from musehub.crypto.keys import key_fingerprint
72
73 # Locate the identity repo.
74 result = await session.execute(
75 select(MusehubRepo).where(
76 MusehubRepo.owner == handle,
77 MusehubRepo.slug == "identity",
78 )
79 )
80 repo = result.scalar_one_or_none()
81 if repo is None:
82 return None
83
84 # Read HEAD manifest.
85 import msgpack
86
87 branch_result = await session.execute(
88 select(MusehubBranch).where(
89 MusehubBranch.repo_id == repo.repo_id,
90 MusehubBranch.name == "main",
91 )
92 )
93 branch = branch_result.scalar_one_or_none()
94 if branch is None or branch.head_commit_id is None:
95 return None
96
97 commit = await session.get(MusehubCommit, branch.head_commit_id)
98 if commit is None or commit.snapshot_id is None:
99 return None
100
101 snap = await session.get(MusehubSnapshot, commit.snapshot_id)
102 if snap is None:
103 return None
104
105 manifest: dict[str, str] = msgpack.unpackb(snap.manifest_blob, raw=False)
106 record_path = f"identities/{handle}.json"
107 object_id = manifest.get(record_path)
108 if object_id is None:
109 return None
110
111 obj = await session.get(MusehubObject, object_id)
112 if obj is None:
113 return None
114
115 content = await read_object_bytes(obj, session=session)
116 if not content:
117 return None
118
119 try:
120 record = _json.loads(content)
121 except Exception:
122 return None
123
124 pubkey_str: str | None = record.get("pubkey")
125 if not pubkey_str:
126 return None
127
128 # Decode "ed25519:<base64url>" → raw bytes → fingerprint.
129 try:
130 from muse.core.types import decode_pubkey
131 _, raw_bytes = decode_pubkey(pubkey_str)
132 return key_fingerprint(raw_bytes)
133 except Exception:
134 return None
135
136 async def check_quorum(
137 session: AsyncSession,
138 repo_id: str,
139 proposal_id: str,
140 governance: JSONObject,
141 ) -> tuple[bool, int, int]:
142 """Return (threshold_met, member_approvals_found, threshold).
143
144 Counts distinct approved reviews whose reviewer's key matches a quorum member.
145
146 Each entry in ``governance["quorum"]["members"]`` is resolved as:
147 - Starts with ``sha256:`` → treated as a raw fingerprint (backward compat).
148 - Otherwise → treated as a handle; resolved to a fingerprint via the
149 handle's identity repo HEAD (Phase 5 behaviour).
150 """
151 quorum = governance.get("quorum", {})
152 threshold: int = quorum.get("threshold", 1)
153 members: list[str] = quorum.get("members", [])
154
155 if not members:
156 return False, 0, threshold
157
158 # Fetch all approved reviews for this proposal.
159 stmt = select(MusehubProposalReview).where(
160 MusehubProposalReview.proposal_id == proposal_id,
161 MusehubProposalReview.state == "approved",
162 )
163 reviews = (await session.execute(stmt)).scalars().all()
164
165 if not reviews:
166 return False, 0, threshold
167
168 # Build a set of accepted fingerprints, resolving handles on the way.
169 accepted_fps: set[str] = set()
170 for member in members:
171 if member.startswith("sha256:"):
172 accepted_fps.add(member)
173 else:
174 fp = await resolve_handle_to_fingerprint(session, member)
175 if fp is not None:
176 accepted_fps.add(fp)
177
178 if not accepted_fps:
179 return False, 0, threshold
180
181 # Count distinct reviewers whose registered key is in accepted_fps.
182 found = 0
183 counted_handles: set[str] = set()
184
185 for review in reviews:
186 handle = review.reviewer_username
187 if handle in counted_handles:
188 continue
189
190 identity_stmt = select(MusehubIdentity).where(
191 MusehubIdentity.handle == handle
192 )
193 identity = (await session.execute(identity_stmt)).scalar_one_or_none()
194 if identity is None:
195 continue
196
197 keys_stmt = select(auth_db.MusehubAuthKey).where(
198 auth_db.MusehubAuthKey.identity_id == identity.identity_id
199 )
200 keys = (await session.execute(keys_stmt)).scalars().all()
201 if any(k.fingerprint in accepted_fps for k in keys):
202 found += 1
203 counted_handles.add(handle)
204
205 return found >= threshold, found, threshold
File History 1 commit
sha256:92528ae07d0e1239d87fd5fd1f439e8fbb49c9778a9a400bc4a736073fb28316 feat: byte-range blob reads, file attribution DAG walk, bra… Sonnet 4.6 minor 43 days ago