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