gabriel / musehub public
musehub_identity_models.py python
265 lines 13.4 KB
Raw
sha256:e519738f2102d625d47828a17d113283cd744577b43551013cb668fd04f1c3fe chore(mwp8/phase5): tick all RG checkboxes; close-out doc (… Sonnet 4.6 18 days ago
1 """ORM models for identity, session auth, attestations, and micropayments.
2
3 Tables:
4 - musehub_identities: Unified identity — human, agent, or org
5 - musehub_attestation_claim_types: Registry of valid attestation claim types
6 - musehub_attestations: Cryptographic attestations issued by one identity about another
7 - musehub_mpay_claims: nanoMUSE micropayment claims
8 - musehub_profile_snapshots: Pre-computed profile snapshots
9 """
10
11 from __future__ import annotations
12
13 from datetime import datetime, timezone
14
15 import sqlalchemy as sa
16 from sqlalchemy import ARRAY, Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint
17 from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column
18 from sqlalchemy.dialects.postgresql import JSONB
19
20 from musehub.db.database import Base
21 from musehub.types.json_types import JSONValue # noqa: F401 — needed for ForwardRef resolution in Mapped[]
22
23
24 def _utc_now() -> datetime:
25 return datetime.now(tz=timezone.utc)
26
27
28 class MusehubIdentity(MappedAsDataclass, Base):
29 """Unified identity — human, agent, or org.
30
31 Replaces the split between ``musehub_profiles`` (humans only) and ad-hoc
32 agent references. All three identity types share one table so that
33 ``/{handle}`` resolves identically and the profile page renders adaptively.
34
35 ``identity_type`` values:
36 ``"human"`` — a person authenticated via MSign key pair / personal access token
37 ``"agent"`` — an autonomous process (LLM, pipeline, CI bot)
38 ``"org"`` — an organisation; membership managed via a join table
39
40 Human identities carry an ``email``; agent identities carry ``agent_model``
41 and ``agent_capabilities`` so clients can inspect before attempting ops.
42 """
43
44 __tablename__ = "musehub_identities"
45 __table_args__ = (
46 UniqueConstraint("handle", name="uq_musehub_identities_handle"),
47 sa.CheckConstraint("identity_type IN ('human', 'agent', 'org')", name="ck_musehub_identities_identity_type"),
48 # Phase 7: author_type JOIN filter — WHERE handle IN (?) AND identity_type = 'agent'
49 Index("ix_musehub_identities_handle_type", "handle", "identity_type"),
50 )
51
52 # --- Required fields ---
53 identity_id: Mapped[str] = mapped_column(String(128), primary_key=True)
54 # URL-safe handle — forms /{handle} and /{handle}/{repo} paths
55 handle: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
56
57 # --- Optional fields with Python-side defaults ---
58 # "human" | "agent" | "org"
59 identity_type: Mapped[str] = mapped_column(String(16), nullable=False, default="human", server_default="human")
60 display_name: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
61 bio: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
62 avatar_url: Mapped[str | None] = mapped_column(String(2048), nullable=True, default=None)
63 website_url: Mapped[str | None] = mapped_column(String(2048), nullable=True, default=None)
64 # Humans only
65 email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True, default=None)
66 # Agents only
67 agent_model: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
68 # JSON list of capability strings, e.g. ["push", "pull", "create-proposal"]
69 agent_capabilities: Mapped[list[str]] = mapped_column(ARRAY(Text), nullable=False, default_factory=list)
70 location: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
71 social_url: Mapped[str | None] = mapped_column(String(2048), nullable=True, default=None)
72 is_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=sa.false(), index=True)
73 # MuseHub-level privilege flag — separate from identity_type. Set by operator action, never by self-registration.
74 is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=sa.false())
75 cc_license: Mapped[str | None] = mapped_column(String(50), nullable=True, default=None)
76 pinned_repo_ids: Mapped[list[str]] = mapped_column(ARRAY(String(128)), nullable=False, default_factory=list)
77 created_at: Mapped[datetime] = mapped_column(
78 DateTime(timezone=True), nullable=False, default_factory=_utc_now
79 )
80 updated_at: Mapped[datetime] = mapped_column(
81 DateTime(timezone=True), nullable=False, default_factory=_utc_now, onupdate=_utc_now
82 )
83 deleted_at: Mapped[datetime | None] = mapped_column(
84 DateTime(timezone=True), nullable=True, default=None
85 )
86 # Agent delegation fields — null for human/org identities
87 spawned_by: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True, default=None)
88 scope: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True, default=None)
89 expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True, default=None)
90 # Compliance: ToS acceptance recorded at first key registration.
91 tos_accepted_at: Mapped[datetime | None] = mapped_column(
92 DateTime(timezone=True), nullable=True, default=None
93 )
94 tos_version: Mapped[str | None] = mapped_column(String(16), nullable=True, default=None)
95 # AVAX C-Chain address derived from Ed25519 pubkey via SLIP-0010/BIP32
96 avax_address: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None)
97 # Org-only fields — null for human/agent identities
98 org_members: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True, default=None)
99 org_quorum: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
100 org_treasury_address: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None)
101
102
103 class MusehubAttestationClaimType(Base):
104 """Registry of valid attestation claim types.
105
106 Claim types are addable at runtime without a deploy. ``valid_scopes``
107 constrains which scopes (identity, repo, commit) a given type may be used
108 with. ``deprecated_at`` soft-retires a type without removing historical
109 attestations that used it.
110
111 Seeded via migration 0043; extended at runtime via the hub API or CLI.
112 """
113
114 __tablename__ = "musehub_attestation_claim_types"
115
116 type_key: Mapped[str] = mapped_column(String(128), primary_key=True)
117 category: Mapped[str] = mapped_column(String(64), nullable=False)
118 label: Mapped[str] = mapped_column(String(128), nullable=False)
119 description: Mapped[str] = mapped_column(Text, nullable=False)
120 # PostgreSQL TEXT[] — list of valid scope strings
121 valid_scopes: Mapped[list[str]] = mapped_column(ARRAY(Text), nullable=False)
122 introduced_at: Mapped[datetime] = mapped_column(
123 DateTime(timezone=True), nullable=False, default=_utc_now
124 )
125 deprecated_at: Mapped[datetime | None] = mapped_column(
126 DateTime(timezone=True), nullable=True, default=None
127 )
128 # Optional JSON Schema for the freeform ``metadata`` field in the claim payload
129 metadata_schema: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=None)
130
131
132 class MusehubAttestation(Base):
133 """A cryptographic attestation issued by one identity about another.
134
135 The attester signs the canonical ATTEST message with their Ed25519 private
136 key. The resulting signature is stored so any client can verify the claim
137 without trusting the server.
138
139 Canonical message for identity scope (UTF-8, newline separated):
140 ATTEST\\n{attester}\\n{subject}\\n{claim}\\n{issued_at_iso}
141
142 For repo/commit scope, scope_ref is appended as a sixth line:
143 ATTEST\\n{attester}\\n{subject}\\n{claim}\\n{issued_at_iso}\\n{scope_ref}
144
145 ``scope`` is one of: ``identity`` | ``repo`` | ``commit``.
146 ``scope_ref`` encodes the full scoped target:
147 identity → None
148 repo → ``{handle}/{repo_slug}``
149 commit → ``{handle}/{repo_slug}@{sha256:commit_id}``
150
151 ``claim`` is a JSON string whose top-level ``type`` key must be a registered
152 claim type. The type must be valid for the given scope.
153
154 ``revoked_at`` is set (never deleted) when the attester retracts a claim.
155 ``expires_at`` is optional; expired attestations are excluded from live
156 queries but retained for audit purposes.
157 """
158
159 __tablename__ = "musehub_attestations"
160 __table_args__ = (
161 Index("ix_musehub_attestations_subject", "subject"),
162 Index("ix_musehub_attestations_attester", "attester"),
163 Index("ix_musehub_attestations_commit_id", "commit_id"),
164 Index("ix_musehub_attestations_scope", "scope"),
165 )
166
167 attestation_id: Mapped[str] = mapped_column(String(128), primary_key=True)
168 attester: Mapped[str] = mapped_column(String(64), nullable=False)
169 # For identity scope: the attested handle.
170 # For repo/commit scope: the repo slug (handle/repo) for efficient joins.
171 subject: Mapped[str] = mapped_column(String(128), nullable=False)
172 # Free-form JSON; top-level "type" must be in musehub_attestation_claim_types
173 claim: Mapped[str] = mapped_column(Text, nullable=False)
174 # Ed25519 signature over canonical ATTEST message; "ed25519:<base64url>"
175 signature: Mapped[str] = mapped_column(String(128), nullable=False)
176 # Attester's Ed25519 public key at time of issuance; "ed25519:<base64url>"
177 attester_public_key: Mapped[str] = mapped_column(String(128), nullable=False)
178 issued_at: Mapped[datetime] = mapped_column(
179 DateTime(timezone=True), nullable=False, default=_utc_now
180 )
181 # Null until the attester revokes the claim
182 revoked_at: Mapped[datetime | None] = mapped_column(
183 DateTime(timezone=True), nullable=True, default=None
184 )
185 # Scope fields — non-null default "identity" preserves backward compatibility
186 scope: Mapped[str] = mapped_column(String(32), nullable=False, default="identity")
187 scope_ref: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
188 repo_id: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None)
189 commit_id: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None)
190 expires_at: Mapped[datetime | None] = mapped_column(
191 DateTime(timezone=True), nullable=True, default=None
192 )
193
194
195 class MusehubMPayClaim(Base):
196 """A single nanoMUSE micropayment claim, Ed25519-signed by the sender.
197
198 The sender signs the canonical MPay message via ``muse sign payment``.
199 This record carries the full claim so recipients and auditors can verify
200 the payment independently of the server.
201
202 ``amount_nano`` is denominated in nanoMUSE (1 MUSE = 1_000_000_000 nanoMUSE).
203 ``nonce_hex`` is the 32-byte random hex embedded in the MSign payment payload.
204
205 ``confirmed_at`` is set by the server when the on-chain transaction is
206 confirmed (for L1-backed claims); null for off-chain credit claims.
207 ``voided_at`` is set if the server detects a double-spend or invalid sig.
208 """
209
210 __tablename__ = "musehub_mpay_claims"
211 __table_args__ = (
212 Index("ix_musehub_mpay_claims_sender", "sender"),
213 Index("ix_musehub_mpay_claims_recipient", "recipient"),
214 UniqueConstraint("nonce_hex", name="uq_musehub_mpay_claims_nonce"),
215 )
216
217 claim_id: Mapped[str] = mapped_column(String(128), primary_key=True)
218 sender: Mapped[str] = mapped_column(String(64), nullable=False)
219 recipient: Mapped[str] = mapped_column(String(64), nullable=False)
220 amount_nano: Mapped[int] = mapped_column(Integer, nullable=False)
221 nonce_hex: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
222 # "ed25519:<base64url>" — signature over canonical MPay message
223 signature: Mapped[str] = mapped_column(String(128), nullable=False)
224 # Sender's public key at time of signing; "ed25519:<base64url>"
225 sender_public_key: Mapped[str] = mapped_column(String(128), nullable=False)
226 # Optional memo attached to the payment
227 memo: Mapped[str | None] = mapped_column(String(500), nullable=True)
228 created_at: Mapped[datetime] = mapped_column(
229 DateTime(timezone=True), nullable=False, default=_utc_now
230 )
231 confirmed_at: Mapped[datetime | None] = mapped_column(
232 DateTime(timezone=True), nullable=True, default=None
233 )
234 voided_at: Mapped[datetime | None] = mapped_column(
235 DateTime(timezone=True), nullable=True, default=None
236 )
237
238
239 class MusehubProfileSnapshot(Base):
240 """Pre-computed profile snapshot — one row per handle.
241
242 Computed asynchronously by the background worker (job_type "profile.snapshot")
243 after any push to a repo owned by that handle. The SSR profile route reads
244 from this table first; if the row is missing or ``is_stale`` the route falls
245 back to live computation and enqueues a refresh job.
246
247 ``data_json`` is a JSON object with all the expensive, cross-repo aggregates
248 that would otherwise be computed on every page request:
249 stats — {repo_count, commit_count, agent_count, avg_health}
250 repos — list of repo card dicts
251 heatmap — 52-week heatmap with streaks
252 agent_fleet — list of agent summary dicts
253 badges — list of provenance badge dicts
254 footprint — list of top-touched symbol dicts
255 activity_canvas — list of domain canvas dicts
256 """
257
258 __tablename__ = "musehub_profile_snapshots"
259
260 handle: Mapped[str] = mapped_column(String(64), primary_key=True)
261 data_json: Mapped[str] = mapped_column(sa.Text, nullable=False, default="{}")
262 computed_at: Mapped[datetime] = mapped_column(
263 DateTime(timezone=True), nullable=False, default=_utc_now
264 )
265 is_stale: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=sa.false())
File History 2 commits
sha256:e519738f2102d625d47828a17d113283cd744577b43551013cb668fd04f1c3fe chore(mwp8/phase5): tick all RG checkboxes; close-out doc (… Sonnet 4.6 18 days ago
sha256:0e5174da777684df43b2db71f282079030ef28bacffb93e19c29ee712b2a8bc4 test(mwp8/phase0): audit — repair+force-push leave stale ca… Sonnet 4.6 20 days ago